profile picture, birthday format and refresh token expiry fixes

This commit is contained in:
Yared Yemane 2026-01-28 09:24:31 -08:00
parent 839c8345a4
commit 7f1bf0e7f1
22 changed files with 878 additions and 95 deletions

View File

@ -216,6 +216,16 @@
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE devices (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
device_token TEXT NOT NULL,
platform VARCHAR(20), -- web, android, ios
is_active BOOLEAN DEFAULT TRUE,
last_seen TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS notifications (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,

39
db/query/device.sql Normal file
View File

@ -0,0 +1,39 @@
-- name: CreateDevice :one
INSERT INTO devices (
user_id,
device_token,
platform
)
VALUES (
$1, $2, $3
)
ON CONFLICT (user_id, device_token)
DO UPDATE SET
is_active = true,
last_seen = CURRENT_TIMESTAMP
RETURNING *;
-- name: GetUserDevices :many
SELECT *
FROM devices
WHERE user_id = $1 AND is_active = true;
-- name: UpdateDeviceLastSeen :exec
UPDATE devices
SET last_seen = CURRENT_TIMESTAMP
WHERE id = $1;
-- name: DeactivateDevice :exec
UPDATE devices
SET is_active = false
WHERE id = $1;
-- name: DeactivateUserDevices :exec
UPDATE devices
SET is_active = false
WHERE user_id = $1;
-- name: GetActiveDeviceTokens :many
SELECT device_token
FROM devices
WHERE user_id = $1 AND is_active = true AND platform IN ('android', 'ios');

View File

@ -3078,6 +3078,62 @@ const docTemplate = `{
}
}
},
"/api/v1/user/{id}/profile-picture": {
"post": {
"security": [
{
"Bearer": []
}
],
"description": "Uploads a profile picture for the specified user",
"consumes": [
"multipart/form-data"
],
"produces": [
"application/json"
],
"tags": [
"user"
],
"summary": "Upload profile picture",
"parameters": [
{
"type": "integer",
"description": "User ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "file",
"description": "Image file (jpg|png|webp)",
"name": "file",
"in": "formData",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/response.APIResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/response.APIResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/response.APIResponse"
}
}
}
}
},
"/api/v1/user/{user_id}/is-pending": {
"get": {
"description": "Returns whether the specified user has a status of \"pending\"",
@ -4162,6 +4218,7 @@ const docTemplate = `{
"type": "string"
},
"birth_day": {
"description": "formatted as YYYY-MM-DD",
"type": "string"
},
"country": {

View File

@ -3070,6 +3070,62 @@
}
}
},
"/api/v1/user/{id}/profile-picture": {
"post": {
"security": [
{
"Bearer": []
}
],
"description": "Uploads a profile picture for the specified user",
"consumes": [
"multipart/form-data"
],
"produces": [
"application/json"
],
"tags": [
"user"
],
"summary": "Upload profile picture",
"parameters": [
{
"type": "integer",
"description": "User ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "file",
"description": "Image file (jpg|png|webp)",
"name": "file",
"in": "formData",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/response.APIResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/response.APIResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/response.APIResponse"
}
}
}
}
},
"/api/v1/user/{user_id}/is-pending": {
"get": {
"description": "Returns whether the specified user has a status of \"pending\"",
@ -4154,6 +4210,7 @@
"type": "string"
},
"birth_day": {
"description": "formatted as YYYY-MM-DD",
"type": "string"
},
"country": {

View File

@ -266,6 +266,7 @@ definitions:
age_group:
type: string
birth_day:
description: formatted as YYYY-MM-DD
type: string
country:
type: string
@ -2984,6 +2985,42 @@ paths:
summary: Update user profile
tags:
- user
/api/v1/user/{id}/profile-picture:
post:
consumes:
- multipart/form-data
description: Uploads a profile picture for the specified user
parameters:
- description: User ID
in: path
name: id
required: true
type: integer
- description: Image file (jpg|png|webp)
in: formData
name: file
required: true
type: file
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/response.APIResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/response.APIResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/response.APIResponse'
security:
- Bearer: []
summary: Upload profile picture
tags:
- user
/api/v1/user/{user_id}/is-pending:
get:
consumes:

142
gen/db/device.sql.go Normal file
View File

@ -0,0 +1,142 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: device.sql
package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const CreateDevice = `-- name: CreateDevice :one
INSERT INTO devices (
user_id,
device_token,
platform
)
VALUES (
$1, $2, $3
)
ON CONFLICT (user_id, device_token)
DO UPDATE SET
is_active = true,
last_seen = CURRENT_TIMESTAMP
RETURNING id, user_id, device_token, platform, is_active, last_seen, created_at
`
type CreateDeviceParams struct {
UserID int64 `json:"user_id"`
DeviceToken string `json:"device_token"`
Platform pgtype.Text `json:"platform"`
}
func (q *Queries) CreateDevice(ctx context.Context, arg CreateDeviceParams) (Device, error) {
row := q.db.QueryRow(ctx, CreateDevice, arg.UserID, arg.DeviceToken, arg.Platform)
var i Device
err := row.Scan(
&i.ID,
&i.UserID,
&i.DeviceToken,
&i.Platform,
&i.IsActive,
&i.LastSeen,
&i.CreatedAt,
)
return i, err
}
const DeactivateDevice = `-- name: DeactivateDevice :exec
UPDATE devices
SET is_active = false
WHERE id = $1
`
func (q *Queries) DeactivateDevice(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, DeactivateDevice, id)
return err
}
const DeactivateUserDevices = `-- name: DeactivateUserDevices :exec
UPDATE devices
SET is_active = false
WHERE user_id = $1
`
func (q *Queries) DeactivateUserDevices(ctx context.Context, userID int64) error {
_, err := q.db.Exec(ctx, DeactivateUserDevices, userID)
return err
}
const GetActiveDeviceTokens = `-- name: GetActiveDeviceTokens :many
SELECT device_token
FROM devices
WHERE user_id = $1 AND is_active = true AND platform IN ('android', 'ios')
`
func (q *Queries) GetActiveDeviceTokens(ctx context.Context, userID int64) ([]string, error) {
rows, err := q.db.Query(ctx, GetActiveDeviceTokens, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []string
for rows.Next() {
var device_token string
if err := rows.Scan(&device_token); err != nil {
return nil, err
}
items = append(items, device_token)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetUserDevices = `-- name: GetUserDevices :many
SELECT id, user_id, device_token, platform, is_active, last_seen, created_at
FROM devices
WHERE user_id = $1 AND is_active = true
`
func (q *Queries) GetUserDevices(ctx context.Context, userID int64) ([]Device, error) {
rows, err := q.db.Query(ctx, GetUserDevices, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Device
for rows.Next() {
var i Device
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.DeviceToken,
&i.Platform,
&i.IsActive,
&i.LastSeen,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const UpdateDeviceLastSeen = `-- name: UpdateDeviceLastSeen :exec
UPDATE devices
SET last_seen = CURRENT_TIMESTAMP
WHERE id = $1
`
func (q *Queries) UpdateDeviceLastSeen(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, UpdateDeviceLastSeen, id)
return err
}

View File

@ -86,6 +86,16 @@ type CourseCategory struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type Device struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
DeviceToken string `json:"device_token"`
Platform pgtype.Text `json:"platform"`
IsActive pgtype.Bool `json:"is_active"`
LastSeen pgtype.Timestamptz `json:"last_seen"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type GlobalSetting struct {
Key string `json:"key"`
Value string `json:"value"`

31
go.mod
View File

@ -17,22 +17,53 @@ require (
)
require (
cel.dev/expr v0.24.0 // indirect
cloud.google.com/go v0.121.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
cloud.google.com/go/firestore v1.18.0 // indirect
cloud.google.com/go/iam v1.5.2 // indirect
cloud.google.com/go/longrunning v0.6.7 // indirect
cloud.google.com/go/monitoring v1.24.2 // indirect
cloud.google.com/go/storage v1.53.0 // indirect
firebase.google.com/go/v4 v4.19.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 // indirect
github.com/MicahParks/keyfunc v1.9.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-jose/go-jose/v4 v4.1.2 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/zeebo/errs v1.4.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.36.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.39.0 // indirect
go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/otel/sdk v1.39.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/api v0.239.0 // indirect
google.golang.org/appengine/v2 v2.0.6 // indirect
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b // indirect
google.golang.org/grpc v1.76.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect

70
go.sum
View File

@ -1,10 +1,36 @@
cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY=
cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
cloud.google.com/go v0.121.0 h1:pgfwva8nGw7vivjZiRfrmglGWiCJBP+0OmDpenG/Fwg=
cloud.google.com/go v0.121.0/go.mod h1:rS7Kytwheu/y9buoDmu5EIpMMCI4Mb8ND4aeN4Vwj7Q=
cloud.google.com/go/auth v0.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs=
cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
cloud.google.com/go/firestore v1.18.0 h1:cuydCaLS7Vl2SatAeivXyhbhDEIR8BDmtn4egDhIn2s=
cloud.google.com/go/firestore v1.18.0/go.mod h1:5ye0v48PhseZBdcl0qbl3uttu7FIEwEYVaWm0UIEOEU=
cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8=
cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE=
cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFsS/PrE=
cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY=
cloud.google.com/go/monitoring v1.24.2 h1:5OTsoJ1dXYIiMiuL+sYscLc9BumrL3CarVLL7dd7lHM=
cloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U=
cloud.google.com/go/storage v1.53.0 h1:gg0ERZwL17pJ+Cz3cD2qS60w1WMDnwcm5YPAIQBHUAw=
cloud.google.com/go/storage v1.53.0/go.mod h1:7/eO2a/srr9ImZW9k5uufcNahT2+fPb8w5it1i5boaA=
firebase.google.com/go/v4 v4.19.0 h1:f5NMlC2YHFsncz00c2+ecBr+ZYlRMhKIhj1z8Iz0lD8=
firebase.google.com/go/v4 v4.19.0/go.mod h1:P7UfBpzc8+Z3MckX79+zsWzKVfpGryr6HLbAe7gCWfs=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 h1:UQUsRi8WTzhZntp5313l+CHIAT95ojUI2lpP/ExlZa4=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0/go.mod h1:Cz6ft6Dkn3Et6l2v2a9/RpN7epQ1GtDlO6lj8bEcOvw=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 h1:fYE9p3esPxA/C0rQ0AHhP0drtPXDRhaWiwg1DPqO7IU=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0/go.mod h1:BnBReJLvVYx2CS/UHOgVz2BXKXD9wsQPxZug20nZhd0=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 h1:6/0iUd0xrnX7qt+mLNRwg5c0PGv8wpE8K90ryANQwMI=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
@ -26,16 +52,25 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls=
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M=
github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A=
github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw=
github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI=
github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@ -62,13 +97,20 @@ github.com/go-playground/validator/v10 v10.29.0/go.mod h1:D6QxqeMlgIPuT02L66f2cc
github.com/gofiber/fiber/v2 v2.32.0/go.mod h1:CMy5ZLiXkn6qwthrl03YMyW1NLfj0rhxz2LKl4t7ZTY=
github.com/gofiber/fiber/v2 v2.52.10 h1:jRHROi2BuNti6NYXmZ6gbNSfT3zj/8c0xy94GOU5elY=
github.com/gofiber/fiber/v2 v2.52.10/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
@ -133,6 +175,8 @@ github.com/otiai10/mint v1.3.3/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/resend/resend-go/v2 v2.28.0 h1:ttM1/VZR4fApBv3xI1TneSKi1pbfFsVrq7fXFlHKtj4=
@ -146,6 +190,8 @@ github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE=
github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@ -190,16 +236,26 @@ github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfS
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM=
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss=
go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/detectors/gcp v1.36.0 h1:F7q2tNlCaHY9nMKHR6XH9/qkp8FktLnIcy6jJNyOCQw=
go.opentelemetry.io/contrib/detectors/gcp v1.36.0/go.mod h1:IbBN8uAIIx734PTonTPxAxnjc2pQTxWNkwfstZ+6H2k=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
@ -265,6 +321,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
@ -275,11 +333,23 @@ golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.239.0 h1:2hZKUnFZEy81eugPs4e2XzIJ5SOwQg0G82bpXD65Puo=
google.golang.org/api v0.239.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50=
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw=
google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4=
google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s=
google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b h1:ULiyYQ0FdsJhwwZUwbaXpZF5yUE3h+RA+gxvBu37ucc=
google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:oDOGiMSXHL4sDTJvFvIB9nRQCGdLP1o/iVaqQK8zB+M=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b h1:zPKJod4w6F1+nRGDI9ubnXYhU9NSWoFAijkHkUXeTK8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@ -166,6 +166,7 @@ type Config struct {
TwilioSenderPhoneNumber string
RedisAddr string
KafkaBrokers []string
FCMServiceAccountKey string
}
func NewConfig() (*Config, error) {
@ -511,6 +512,8 @@ func (c *Config) loadEnv() error {
}
c.TwilioSenderPhoneNumber = twilioSenderPhoneNumber
c.FCMServiceAccountKey = os.Getenv("FCM_SERVICE_ACCOUNT_KEY")
return nil
}

View File

@ -14,11 +14,20 @@ type ResponseWDataFactory[T any] struct {
type Response struct {
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
Success bool `json:"success" default:"true"`
StatusCode int `json:"status_code" default:"200"`
Success bool `json:"success"`
StatusCode int `json:"status_code"`
MetaData interface{} `json:"metadata"`
}
func NewResponse(message string, data interface{}) Response {
return Response{
Message: message,
Data: data,
Success: true, // default true
StatusCode: 200, // default 200
}
}
// type CallbackErrorResponse struct {
// ErrorData interface
// Error string `json:"error,omitempty"`

View File

@ -83,11 +83,11 @@ type User struct {
}
type UserProfileResponse struct {
ID int64 `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Gender string `json:"gender"`
BirthDay time.Time `json:"birth_day"`
ID int64 `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Gender string `json:"gender"`
BirthDay string `json:"birth_day,omitempty"` // formatted as YYYY-MM-DD
// UserName string `json:"user_name,omitempty"`
Email string `json:"email,omitempty"`
PhoneNumber string `json:"phone_number,omitempty"`
@ -113,7 +113,7 @@ type UserProfileResponse struct {
LastLogin *time.Time `json:"last_login,omitempty"`
ProfileCompleted bool `json:"profile_completed"`
ProfilePictureURL string `json:"profile_picture_url,omitempty"`
ProfilePictureURL string `json:"profile_picture_url"`
PreferredLanguage string `json:"preferred_language,omitempty"`
CreatedAt time.Time `json:"created_at"`

View File

@ -66,6 +66,8 @@ type UserStore interface {
phone string,
) (domain.User, error)
UpdatePassword(ctx context.Context, password string, userID int64) error
RegisterDevice(ctx context.Context, userID int64, deviceToken, platform string) error
GetUserDeviceTokens(ctx context.Context, userID int64) ([]string, error)
}
type SmsGateway interface {
SendSMSOTP(ctx context.Context, phoneNumber, otp string) error

View File

@ -21,7 +21,14 @@ func NewTokenStore(s *Store) ports.TokenStore {
// CreateRefreshToken inserts a new refresh token into the database
func (s *Store) CreateRefreshToken(ctx context.Context, rt domain.RefreshToken) error {
rt.ExpiresAt = time.Now().Add(10 * time.Minute)
// Use provided ExpiresAt and CreatedAt when available; otherwise fall back to sensible defaults.
if rt.ExpiresAt.IsZero() {
rt.ExpiresAt = time.Now().Add(10 * time.Minute)
}
createdAt := rt.CreatedAt
if createdAt.IsZero() {
createdAt = time.Now()
}
return s.queries.CreateRefreshToken(ctx, dbgen.CreateRefreshTokenParams{
UserID: rt.UserID,
@ -31,7 +38,7 @@ func (s *Store) CreateRefreshToken(ctx context.Context, rt domain.RefreshToken)
Valid: true,
},
CreatedAt: pgtype.Timestamptz{
Time: time.Now(),
Time: createdAt,
Valid: true,
},
Revoked: rt.Revoked,

View File

@ -16,6 +16,19 @@ import (
func NewUserStore(s *Store) ports.UserStore { return s }
func (s *Store) GetUserDeviceTokens(ctx context.Context, userID int64) ([]string, error) {
return s.queries.GetActiveDeviceTokens(ctx, userID)
}
func (s *Store) RegisterDevice(ctx context.Context, userID int64, deviceToken, platform string) error {
_, err := s.queries.CreateDevice(ctx, dbgen.CreateDeviceParams{
UserID: userID,
DeviceToken: deviceToken,
Platform: pgtype.Text{String: platform},
})
return err
}
func (s *Store) LinkGoogleAccount(
ctx context.Context,
userID int64,
@ -652,65 +665,6 @@ func (s *Store) CheckPhoneEmailExist(ctx context.Context, phone, email string) (
return res.PhoneExists, res.EmailExists, nil
}
// func (s *Store) GetUserByUserName(
// ctx context.Context,
// userName string,
// ) (domain.User, error) {
// u, err := s.queries.GetUserByUserName(ctx, userName)
// if err != nil {
// if errors.Is(err, pgx.ErrNoRows) {
// return domain.User{}, authentication.ErrUserNotFound
// }
// return domain.User{}, err
// }
// var lastLogin *time.Time
// if u.LastLogin.Valid {
// lastLogin = &u.LastLogin.Time
// }
// var updatedAt *time.Time
// if u.UpdatedAt.Valid {
// updatedAt = &u.UpdatedAt.Time
// }
// return domain.User{
// ID: u.ID,
// FirstName: u.FirstName,
// LastName: u.LastName,
// UserName: u.UserName,
// Email: u.Email.String,
// PhoneNumber: u.PhoneNumber.String,
// Password: u.Password,
// Role: domain.Role(u.Role),
// Age: int(u.Age.Int32),
// EducationLevel: u.EducationLevel.String,
// Country: u.Country.String,
// Region: u.Region.String,
// NickName: u.NickName.String,
// Occupation: u.Occupation.String,
// LearningGoal: u.LearningGoal.String,
// LanguageGoal: u.LanguageGoal.String,
// LanguageChallange: u.LanguageChallange.String,
// FavouriteTopic: u.FavouriteTopic.String,
// EmailVerified: u.EmailVerified,
// PhoneVerified: u.PhoneVerified,
// Status: domain.UserStatus(u.Status),
// LastLogin: lastLogin,
// ProfileCompleted: u.ProfileCompleted,
// ProfilePictureURL: u.ProfilePictureUrl.String,
// PreferredLanguage: u.PreferredLanguage.String,
// CreatedAt: u.CreatedAt.Time,
// UpdatedAt: updatedAt,
// }, nil
// }
// GetUserByEmail retrieves a user by email and organization
func (s *Store) GetUserByEmailPhone(
ctx context.Context,
@ -869,6 +823,7 @@ func mapDBUser(
PhoneVerified: userRes.PhoneVerified,
Status: domain.UserStatus(userRes.Status),
ProfilePictureURL: userRes.ProfilePictureUrl.String,
ProfileCompleted: userRes.ProfileCompleted.Bool,
PreferredLanguage: userRes.PreferredLanguage.String,

View File

@ -14,6 +14,7 @@ import (
"io"
"net/http"
"net/url"
"strconv"
// "errors"
"log/slog"
@ -23,6 +24,9 @@ import (
// "github.com/segmentio/kafka-go"
"go.uber.org/zap"
// afro "github.com/amanuelabay/afrosms-go"
firebase "firebase.google.com/go/v4"
"firebase.google.com/go/v4/messaging"
"google.golang.org/api/option"
afro "github.com/amanuelabay/afrosms-go"
"github.com/gorilla/websocket"
// "github.com/redis/go-redis/v9"
@ -39,6 +43,7 @@ type Service struct {
messengerSvc *messenger.Service
mongoLogger *zap.Logger
logger *slog.Logger
fcmClient *messaging.Client
}
func New(
@ -64,6 +69,13 @@ func New(
config: cfg,
}
// Initialize FCM client if service account key is provided
if cfg.FCMServiceAccountKey != "" {
if err := svc.initFCMClient(); err != nil {
mongoLogger.Error("Failed to initialize FCM client", zap.Error(err))
}
}
go hub.Run()
go svc.startWorker()
// go svc.startRetryWorker()
@ -73,6 +85,31 @@ func New(
return svc
}
func (s *Service) initFCMClient() error {
ctx := context.Background()
// Prepare client options; if a service account JSON string is provided, use it.
var opts []option.ClientOption
if s.config.FCMServiceAccountKey != "" {
opts = append(opts, option.WithCredentialsJSON([]byte(s.config.FCMServiceAccountKey)))
}
// Initialize Firebase app
app, err := firebase.NewApp(ctx, nil, opts...)
if err != nil {
return fmt.Errorf("failed to initialize Firebase app: %w", err)
}
// Get messaging client
client, err := app.Messaging(ctx)
if err != nil {
return fmt.Errorf("failed to get FCM client: %w", err)
}
s.fcmClient = client
return nil
}
func (s *Service) SendAfroMessageSMS(ctx context.Context, receiverPhone, message string) error {
apiKey := s.config.AFRO_SMS_API_KEY
senderName := s.config.AFRO_SMS_SENDER_NAME
@ -411,6 +448,15 @@ func (s *Service) handleNotification(notification *domain.Notification) {
} else {
notification.DeliveryStatus = domain.DeliveryStatusSent
}
case domain.DeliveryChannelPush:
err := s.SendPushNotification(ctx, notification)
if err != nil {
notification.DeliveryStatus = domain.DeliveryStatusFailed
} else {
notification.DeliveryStatus = domain.DeliveryStatusSent
}
default:
if notification.DeliveryChannel != domain.DeliveryChannelInApp {
s.mongoLogger.Warn("[NotificationSvc.HandleNotification] Unsupported delivery channel",
@ -490,6 +536,64 @@ func (s *Service) SendNotificationEmail(ctx context.Context, recipientID int64,
return nil
}
func (s *Service) SendPushNotification(ctx context.Context, notification *domain.Notification) error {
if s.fcmClient == nil {
return fmt.Errorf("FCM client not initialized")
}
// Get user device tokens
tokens, err := s.userSvc.GetUserDeviceTokens(ctx, notification.RecipientID)
if err != nil {
return fmt.Errorf("failed to get user device tokens: %w", err)
}
if len(tokens) == 0 {
s.mongoLogger.Info("[NotificationSvc.SendPushNotification] No device tokens found for user",
zap.Int64("userID", notification.RecipientID),
zap.Time("timestamp", time.Now()),
)
return nil // Not an error, just no devices to send to
}
// Create FCM message
message := &messaging.Message{
Notification: &messaging.Notification{
Title: notification.Payload.Headline,
Body: notification.Payload.Message,
},
Data: map[string]string{
"type": string(notification.Type),
"recipient_id": strconv.FormatInt(notification.RecipientID, 10),
"notification_id": notification.ID,
},
}
// Send to all user devices
for _, token := range tokens {
message.Token = token
response, err := s.fcmClient.Send(ctx, message)
if err != nil {
s.mongoLogger.Error("[NotificationSvc.SendPushNotification] Failed to send FCM message",
zap.String("token", token),
zap.Int64("userID", notification.RecipientID),
zap.Error(err),
zap.Time("timestamp", time.Now()),
)
continue // Continue with other tokens
}
s.mongoLogger.Info("[NotificationSvc.SendPushNotification] FCM message sent successfully",
zap.String("response", response),
zap.String("token", token),
zap.Int64("userID", notification.RecipientID),
zap.Time("timestamp", time.Now()),
)
}
return nil
}
// func (s *Service) startRetryWorker() {
// ticker := time.NewTicker(1 * time.Minute)
// defer ticker.Stop()

View File

@ -33,6 +33,8 @@ type UserStore interface {
GetCustomerDetails(ctx context.Context, filter domain.ReportFilter) (map[int64]domain.CustomerDetail, error)
GetBranchCustomerCounts(ctx context.Context, filter domain.ReportFilter) (map[int64]int64, error)
GetRoleCounts(ctx context.Context, role string, filter domain.ReportFilter) (total, active, inactive int64, err error)
RegisterDevice(ctx context.Context, userID int64, deviceToken, platform string) error
GetUserDeviceTokens(ctx context.Context, userID int64) ([]string, error)
}
type SmsGateway interface {
SendSMSOTP(ctx context.Context, phoneNumber, otp string) error

View File

@ -22,17 +22,6 @@ func (s *Service) IsProfileCompleted(ctx context.Context, userId int64) (bool, e
return s.userStore.IsProfileCompleted(ctx, userId)
}
// func (s *Service) IsUserNameUnique(ctx context.Context, userID int64) (bool, error) {
// return s.userStore.IsUserNameUnique(ctx, userID)
// }
// func (s *Service) GetUserByUserName(
// ctx context.Context,
// userName string,
// ) (domain.User, error) {
// return s.userStore.GetUserByUserName(ctx, userName)
// }
func (s *Service) SearchUserByNameOrPhone(ctx context.Context, searchString string, role *int64) ([]domain.User, error) {
// Search user
var roleStr *string
@ -49,10 +38,6 @@ func (s *Service) UpdateUser(ctx context.Context, req domain.UpdateUserReq) erro
return s.userStore.UpdateUser(ctx, req)
}
// func (s *Service) UpdateUserSuspend(ctx context.Context, id int64, status bool) error {
// // update user
// return s.userStore.UpdateUserSuspend(ctx, id, status)
// }
func (s *Service) GetUserByID(ctx context.Context, id int64) (domain.User, error) {
return s.userStore.GetUserByID(ctx, id)
}
@ -92,4 +77,12 @@ func (s *Service) GetUserByID(ctx context.Context, id int64) (domain.User, error
// }
// return s.userStore.GetAllUsers(ctx, role, query, createdBefore, createdAfter, limit, offset)
// }
// Device management methods
func (s *Service) RegisterDevice(ctx context.Context, userID int64, deviceToken, platform string) error {
return s.userStore.RegisterDevice(ctx, userID, deviceToken, platform)
}
func (s *Service) GetUserDeviceTokens(ctx context.Context, userID int64) ([]string, error) {
return s.userStore.GetUserDeviceTokens(ctx, userID)
}

View File

@ -523,3 +523,52 @@ func (h *Handler) SendSingleAfroSMS(c *fiber.Ctx) error {
Data: req,
})
}
func (h *Handler) RegisterDeviceToken(c *fiber.Ctx) error {
type Request struct {
DeviceToken string `json:"device_token" validate:"required"`
Platform string `json:"platform" validate:"required,oneof=android ios web"`
}
var req Request
if err := c.BodyParser(&req); err != nil {
h.mongoLoggerSvc.Info("[NotificationHandler.RegisterDeviceToken] Failed to parse request body",
zap.Int("status_code", fiber.StatusBadRequest),
zap.Error(err),
zap.Time("timestamp", time.Now()),
)
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
userID, ok := c.Locals("user_id").(int64)
if !ok || userID == 0 {
h.mongoLoggerSvc.Error("[NotificationHandler.RegisterDeviceToken] Invalid user ID in context",
zap.Int("status_code", fiber.StatusUnauthorized),
zap.Time("timestamp", time.Now()),
)
return fiber.NewError(fiber.StatusUnauthorized, "Invalid user identification")
}
if err := h.userSvc.RegisterDevice(c.Context(), userID, req.DeviceToken, req.Platform); err != nil {
h.mongoLoggerSvc.Error("[NotificationHandler.RegisterDeviceToken] Failed to register device token",
zap.Int64("userID", userID),
zap.String("platform", req.Platform),
zap.Int("status_code", fiber.StatusInternalServerError),
zap.Error(err),
zap.Time("timestamp", time.Now()),
)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to register device token: "+err.Error())
}
h.mongoLoggerSvc.Info("[NotificationHandler.RegisterDeviceToken] Device token registered successfully",
zap.Int64("userID", userID),
zap.String("platform", req.Platform),
zap.Time("timestamp", time.Now()),
)
return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Device token registered successfully",
Success: true,
StatusCode: fiber.StatusCreated,
})
}

View File

@ -7,10 +7,15 @@ import (
"Yimaru-Backend/internal/web_server/response"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"time"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
"go.uber.org/zap"
)
@ -484,7 +489,46 @@ func (h *Handler) GetAllUsers(c *fiber.Ctx) error {
// }
// }
return response.WriteJSON(c, fiber.StatusOK, "Users fetched successfully", map[string]interface{}{"users": users, "total": total}, nil)
// Map domain.User -> domain.UserProfileResponse to control fields and formatting
mapped := make([]domain.UserProfileResponse, 0, len(users))
for _, u := range users {
var bd string
if !u.BirthDay.IsZero() {
bd = u.BirthDay.Format("2006-01-02")
}
mapped = append(mapped, domain.UserProfileResponse{
ID: u.ID,
FirstName: u.FirstName,
LastName: u.LastName,
Gender: u.Gender,
BirthDay: bd,
Email: u.Email,
PhoneNumber: u.PhoneNumber,
Role: u.Role,
AgeGroup: u.AgeGroup,
EducationLevel: u.EducationLevel,
Country: u.Country,
Region: u.Region,
InitialAssessmentCompleted: u.InitialAssessmentCompleted,
NickName: u.NickName,
Occupation: u.Occupation,
LearningGoal: u.LearningGoal,
LanguageGoal: u.LanguageGoal,
LanguageChallange: u.LanguageChallange,
FavouriteTopic: u.FavouriteTopic,
EmailVerified: u.EmailVerified,
PhoneVerified: u.PhoneVerified,
Status: u.Status,
LastLogin: u.LastLogin,
ProfileCompleted: u.ProfileCompleted,
ProfilePictureURL: u.ProfilePictureURL,
PreferredLanguage: u.PreferredLanguage,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
})
}
return response.WriteJSON(c, fiber.StatusOK, "Users fetched successfully", map[string]interface{}{"users": mapped, "total": total}, nil)
}
// VerifyOtp godoc
@ -1236,11 +1280,17 @@ func (h *Handler) GetUserProfile(c *fiber.Ctx) error {
FirstName: user.FirstName,
LastName: user.LastName,
// UserName: user.UserName,
Occupation: user.Occupation,
Email: user.Email,
PhoneNumber: user.PhoneNumber,
Role: user.Role,
AgeGroup: user.AgeGroup,
Occupation: user.Occupation,
Email: user.Email,
PhoneNumber: user.PhoneNumber,
Role: user.Role,
AgeGroup: user.AgeGroup,
BirthDay: func() string {
if !user.BirthDay.IsZero() {
return user.BirthDay.Format("2006-01-02")
}
return ""
}(),
EducationLevel: user.EducationLevel,
Country: user.Country,
Region: user.Region,
@ -1343,6 +1393,10 @@ func (h *Handler) AdminProfile(c *fiber.Ctx) error {
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
}
// Ensure birthday is included and formatted
if !user.BirthDay.IsZero() {
res.BirthDay = user.BirthDay.Format("2006-01-02")
}
return response.WriteJSON(c, fiber.StatusOK, "User profile retrieved successfully", res, nil)
}
@ -1448,7 +1502,13 @@ func (h *Handler) SearchUserByNameOrPhone(c *fiber.Ctx) error {
FirstName: user.FirstName,
LastName: user.LastName,
// UserName: user.UserName,
Email: user.Email,
Email: user.Email,
BirthDay: func() string {
if !user.BirthDay.IsZero() {
return user.BirthDay.Format("2006-01-02")
}
return ""
}(),
PhoneNumber: user.PhoneNumber,
Role: user.Role,
AgeGroup: user.AgeGroup,
@ -1526,9 +1586,15 @@ func (h *Handler) GetUserByID(c *fiber.Ctx) error {
// }
res := domain.UserProfileResponse{
ID: user.ID,
FirstName: user.FirstName,
LastName: user.LastName,
ID: user.ID,
FirstName: user.FirstName,
LastName: user.LastName,
BirthDay: func() string {
if !user.BirthDay.IsZero() {
return user.BirthDay.Format("2006-01-02")
}
return ""
}(),
LearningGoal: user.LearningGoal,
LanguageGoal: user.LanguageGoal,
LanguageChallange: user.LanguageChallange,
@ -1605,3 +1671,133 @@ type UpdateUserSuspendRes struct {
UserID int64 `json:"user_id"`
Suspended bool `json:"suspended"`
}
// UploadProfilePicture godoc
// @Summary Upload profile picture
// @Description Uploads a profile picture for the specified user
// @Tags user
// @Accept multipart/form-data
// @Produce json
// @Param id path int true "User ID"
// @Param file formData file true "Image file (jpg|png|webp)"
// @Success 200 {object} response.APIResponse
// @Failure 400 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Security Bearer
// @Router /api/v1/user/{id}/profile-picture [post]
func (h *Handler) UploadProfilePicture(c *fiber.Ctx) error {
idStr := c.Params("id")
userID, err := strconv.ParseInt(idStr, 10, 64)
if err != nil || userID <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid user id",
Error: "User id must be a positive integer",
})
}
fileHeader, err := c.FormFile("file")
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid file",
Error: err.Error(),
})
}
const maxSize = 5 * 1024 * 1024 // 5 MB
if fileHeader.Size > maxSize {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "File too large",
Error: "Profile picture must be <= 5MB",
})
}
fh, err := fileHeader.Open()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to read file",
Error: err.Error(),
})
}
defer fh.Close()
// Read up to first 512 bytes for content sniffing, then read the rest
head := make([]byte, 512)
n, _ := fh.Read(head)
contentType := http.DetectContentType(head[:n])
if contentType != "image/jpeg" && contentType != "image/png" && contentType != "image/webp" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid file type",
Error: "Only jpg, png and webp images are allowed",
})
}
// Read remaining bytes
rest, err := io.ReadAll(fh)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to read file",
Error: err.Error(),
})
}
// Combine head + rest
data := append(head[:n], rest...)
ext := ".jpg"
switch contentType {
case "image/png":
ext = ".png"
case "image/webp":
ext = ".webp"
}
// Ensure directory exists
dir := filepath.Join(".", "static", "profile_pictures")
if err := os.MkdirAll(dir, 0o755); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to create storage directory",
Error: err.Error(),
})
}
filename := uuid.New().String() + ext
fullpath := filepath.Join(dir, filename)
if err := os.WriteFile(fullpath, data, 0o644); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to save file",
Error: err.Error(),
})
}
publicPath := "/static/profile_pictures/" + filename
// Update user profile picture URL
req := domain.UpdateUserReq{
UserID: userID,
ProfilePictureURL: publicPath,
}
if err := h.userSvc.UpdateUser(c.Context(), req); err != nil {
// Attempt to remove file on failure
_ = os.Remove(fullpath)
h.mongoLoggerSvc.Error("Failed to update user with profile picture",
zap.Int64("user_id", userID),
zap.Error(err),
zap.Time("timestamp", time.Now()),
)
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to update user",
Error: err.Error(),
})
}
return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "Profile picture uploaded successfully",
Data: map[string]string{"profile_picture_url": publicPath},
Success: true,
StatusCode: fiber.StatusOK,
})
// return response.WriteJSON(c, fiber.StatusOK, "Profile picture uploaded successfully", map[string]string{"profile_picture_url": publicPath}, nil)
}

View File

@ -68,6 +68,9 @@ func (a *App) initAppRoutes() {
// })
// })
// Serve static files (profile pictures, etc.)
a.fiber.Static("/static", "./static")
// Get S
groupV1.Get("/tenant", a.authMiddleware, h.GetTenantSlugByToken)
@ -196,6 +199,8 @@ func (a *App) initAppRoutes() {
groupV1.Post("/auth/admin-login", h.LoginAdmin)
groupV1.Post("/auth/super-login", h.LoginSuper)
groupV1.Post("/auth/refresh", h.RefreshToken)
// Upload profile picture
groupV1.Post("/user/:id/profile-picture", a.authMiddleware, h.UploadProfilePicture)
groupV1.Post("/auth/logout", a.authMiddleware, h.LogOutuser)
groupV1.Get("/auth/test", a.authMiddleware, func(c *fiber.Ctx) error {
userID, ok := c.Locals("user_id").(int64)
@ -293,6 +298,9 @@ func (a *App) initAppRoutes() {
// groupV1.Patch("/issues/:issue_id/status", a.authMiddleware, a.OnlyAdminAndAbove, h.UpdateIssueStatus)
// groupV1.Delete("/issues/:issue_id", a.authMiddleware, a.OnlyAdminAndAbove, h.DeleteIssue)
// Device Token Registration
groupV1.Post("/devices/register", a.authMiddleware, h.RegisterDeviceToken)
// Settings
groupV1.Get("/settings", a.authMiddleware, a.SuperAdminOnly, h.GetGlobalSettingList)
groupV1.Get("/settings/:key", a.authMiddleware, a.SuperAdminOnly, h.GetGlobalSettingByKey)

View File

@ -58,3 +58,5 @@ TWILIO_SENDER_PHONE_NUMBER=0912345678
# Redis
REDIS_ADDR=redis:6379
# Firebase Cloud Messaging