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 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 ( CREATE TABLE IF NOT EXISTS notifications (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, 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": { "/api/v1/user/{user_id}/is-pending": {
"get": { "get": {
"description": "Returns whether the specified user has a status of \"pending\"", "description": "Returns whether the specified user has a status of \"pending\"",
@ -4162,6 +4218,7 @@ const docTemplate = `{
"type": "string" "type": "string"
}, },
"birth_day": { "birth_day": {
"description": "formatted as YYYY-MM-DD",
"type": "string" "type": "string"
}, },
"country": { "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": { "/api/v1/user/{user_id}/is-pending": {
"get": { "get": {
"description": "Returns whether the specified user has a status of \"pending\"", "description": "Returns whether the specified user has a status of \"pending\"",
@ -4154,6 +4210,7 @@
"type": "string" "type": "string"
}, },
"birth_day": { "birth_day": {
"description": "formatted as YYYY-MM-DD",
"type": "string" "type": "string"
}, },
"country": { "country": {

View File

@ -266,6 +266,7 @@ definitions:
age_group: age_group:
type: string type: string
birth_day: birth_day:
description: formatted as YYYY-MM-DD
type: string type: string
country: country:
type: string type: string
@ -2984,6 +2985,42 @@ paths:
summary: Update user profile summary: Update user profile
tags: tags:
- user - 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: /api/v1/user/{user_id}/is-pending:
get: get:
consumes: 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"` 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 { type GlobalSetting struct {
Key string `json:"key"` Key string `json:"key"`
Value string `json:"value"` Value string `json:"value"`

31
go.mod
View File

@ -17,22 +17,53 @@ require (
) )
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/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/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/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/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // 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/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
github.com/googleapis/gax-go/v2 v2.15.0 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect
github.com/kr/pretty v0.3.1 // 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/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/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/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/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.39.0 // indirect go.opentelemetry.io/otel v1.39.0 // indirect
go.opentelemetry.io/otel/metric 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 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/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b // indirect
google.golang.org/grpc v1.76.0 // indirect google.golang.org/grpc v1.76.0 // indirect
google.golang.org/protobuf v1.36.10 // 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 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs=
cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA= 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 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= 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/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 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= 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 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= 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= 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/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 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= 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/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/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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 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 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= 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/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.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 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 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.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 h1:jRHROi2BuNti6NYXmZ6gbNSfT3zj/8c0xy94GOU5elY=
github.com/gofiber/fiber/v2 v2.52.10/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= 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.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 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 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 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= 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 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= 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/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 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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= 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/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/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/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.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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 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.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.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 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 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss=
go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= 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 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= 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 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= 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 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= 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 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= 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 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= 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= 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.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= 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/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-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-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 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/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-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-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= 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 h1:zPKJod4w6F1+nRGDI9ubnXYhU9NSWoFAijkHkUXeTK8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= 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 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= 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 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 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= 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 TwilioSenderPhoneNumber string
RedisAddr string RedisAddr string
KafkaBrokers []string KafkaBrokers []string
FCMServiceAccountKey string
} }
func NewConfig() (*Config, error) { func NewConfig() (*Config, error) {
@ -511,6 +512,8 @@ func (c *Config) loadEnv() error {
} }
c.TwilioSenderPhoneNumber = twilioSenderPhoneNumber c.TwilioSenderPhoneNumber = twilioSenderPhoneNumber
c.FCMServiceAccountKey = os.Getenv("FCM_SERVICE_ACCOUNT_KEY")
return nil return nil
} }

View File

@ -14,11 +14,20 @@ type ResponseWDataFactory[T any] struct {
type Response struct { type Response struct {
Message string `json:"message"` Message string `json:"message"`
Data interface{} `json:"data,omitempty"` Data interface{} `json:"data,omitempty"`
Success bool `json:"success" default:"true"` Success bool `json:"success"`
StatusCode int `json:"status_code" default:"200"` StatusCode int `json:"status_code"`
MetaData interface{} `json:"metadata"` 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 { // type CallbackErrorResponse struct {
// ErrorData interface // ErrorData interface
// Error string `json:"error,omitempty"` // Error string `json:"error,omitempty"`

View File

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

View File

@ -66,6 +66,8 @@ type UserStore interface {
phone string, phone string,
) (domain.User, error) ) (domain.User, error)
UpdatePassword(ctx context.Context, password string, userID int64) 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 { type SmsGateway interface {
SendSMSOTP(ctx context.Context, phoneNumber, otp string) error 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 // CreateRefreshToken inserts a new refresh token into the database
func (s *Store) CreateRefreshToken(ctx context.Context, rt domain.RefreshToken) error { 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{ return s.queries.CreateRefreshToken(ctx, dbgen.CreateRefreshTokenParams{
UserID: rt.UserID, UserID: rt.UserID,
@ -31,7 +38,7 @@ func (s *Store) CreateRefreshToken(ctx context.Context, rt domain.RefreshToken)
Valid: true, Valid: true,
}, },
CreatedAt: pgtype.Timestamptz{ CreatedAt: pgtype.Timestamptz{
Time: time.Now(), Time: createdAt,
Valid: true, Valid: true,
}, },
Revoked: rt.Revoked, Revoked: rt.Revoked,

View File

@ -16,6 +16,19 @@ import (
func NewUserStore(s *Store) ports.UserStore { return s } 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( func (s *Store) LinkGoogleAccount(
ctx context.Context, ctx context.Context,
userID int64, userID int64,
@ -652,65 +665,6 @@ func (s *Store) CheckPhoneEmailExist(ctx context.Context, phone, email string) (
return res.PhoneExists, res.EmailExists, nil 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 // GetUserByEmail retrieves a user by email and organization
func (s *Store) GetUserByEmailPhone( func (s *Store) GetUserByEmailPhone(
ctx context.Context, ctx context.Context,
@ -869,6 +823,7 @@ func mapDBUser(
PhoneVerified: userRes.PhoneVerified, PhoneVerified: userRes.PhoneVerified,
Status: domain.UserStatus(userRes.Status), Status: domain.UserStatus(userRes.Status),
ProfilePictureURL: userRes.ProfilePictureUrl.String,
ProfileCompleted: userRes.ProfileCompleted.Bool, ProfileCompleted: userRes.ProfileCompleted.Bool,
PreferredLanguage: userRes.PreferredLanguage.String, PreferredLanguage: userRes.PreferredLanguage.String,

View File

@ -14,6 +14,7 @@ import (
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"strconv"
// "errors" // "errors"
"log/slog" "log/slog"
@ -23,6 +24,9 @@ import (
// "github.com/segmentio/kafka-go" // "github.com/segmentio/kafka-go"
"go.uber.org/zap" "go.uber.org/zap"
// afro "github.com/amanuelabay/afrosms-go" // 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" afro "github.com/amanuelabay/afrosms-go"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
// "github.com/redis/go-redis/v9" // "github.com/redis/go-redis/v9"
@ -39,6 +43,7 @@ type Service struct {
messengerSvc *messenger.Service messengerSvc *messenger.Service
mongoLogger *zap.Logger mongoLogger *zap.Logger
logger *slog.Logger logger *slog.Logger
fcmClient *messaging.Client
} }
func New( func New(
@ -64,6 +69,13 @@ func New(
config: cfg, 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 hub.Run()
go svc.startWorker() go svc.startWorker()
// go svc.startRetryWorker() // go svc.startRetryWorker()
@ -73,6 +85,31 @@ func New(
return svc 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 { func (s *Service) SendAfroMessageSMS(ctx context.Context, receiverPhone, message string) error {
apiKey := s.config.AFRO_SMS_API_KEY apiKey := s.config.AFRO_SMS_API_KEY
senderName := s.config.AFRO_SMS_SENDER_NAME senderName := s.config.AFRO_SMS_SENDER_NAME
@ -411,6 +448,15 @@ func (s *Service) handleNotification(notification *domain.Notification) {
} else { } else {
notification.DeliveryStatus = domain.DeliveryStatusSent 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: default:
if notification.DeliveryChannel != domain.DeliveryChannelInApp { if notification.DeliveryChannel != domain.DeliveryChannelInApp {
s.mongoLogger.Warn("[NotificationSvc.HandleNotification] Unsupported delivery channel", s.mongoLogger.Warn("[NotificationSvc.HandleNotification] Unsupported delivery channel",
@ -490,6 +536,64 @@ func (s *Service) SendNotificationEmail(ctx context.Context, recipientID int64,
return nil 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() { // func (s *Service) startRetryWorker() {
// ticker := time.NewTicker(1 * time.Minute) // ticker := time.NewTicker(1 * time.Minute)
// defer ticker.Stop() // defer ticker.Stop()

View File

@ -33,6 +33,8 @@ type UserStore interface {
GetCustomerDetails(ctx context.Context, filter domain.ReportFilter) (map[int64]domain.CustomerDetail, error) GetCustomerDetails(ctx context.Context, filter domain.ReportFilter) (map[int64]domain.CustomerDetail, error)
GetBranchCustomerCounts(ctx context.Context, filter domain.ReportFilter) (map[int64]int64, 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) 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 { type SmsGateway interface {
SendSMSOTP(ctx context.Context, phoneNumber, otp string) error 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) 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) { func (s *Service) SearchUserByNameOrPhone(ctx context.Context, searchString string, role *int64) ([]domain.User, error) {
// Search user // Search user
var roleStr *string var roleStr *string
@ -49,10 +38,6 @@ func (s *Service) UpdateUser(ctx context.Context, req domain.UpdateUserReq) erro
return s.userStore.UpdateUser(ctx, req) 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) { func (s *Service) GetUserByID(ctx context.Context, id int64) (domain.User, error) {
return s.userStore.GetUserByID(ctx, id) 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) // 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, 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" "Yimaru-Backend/internal/web_server/response"
"errors" "errors"
"fmt" "fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv" "strconv"
"time" "time"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/google/uuid"
"go.uber.org/zap" "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 // VerifyOtp godoc
@ -1236,11 +1280,17 @@ func (h *Handler) GetUserProfile(c *fiber.Ctx) error {
FirstName: user.FirstName, FirstName: user.FirstName,
LastName: user.LastName, LastName: user.LastName,
// UserName: user.UserName, // UserName: user.UserName,
Occupation: user.Occupation, Occupation: user.Occupation,
Email: user.Email, Email: user.Email,
PhoneNumber: user.PhoneNumber, PhoneNumber: user.PhoneNumber,
Role: user.Role, Role: user.Role,
AgeGroup: user.AgeGroup, AgeGroup: user.AgeGroup,
BirthDay: func() string {
if !user.BirthDay.IsZero() {
return user.BirthDay.Format("2006-01-02")
}
return ""
}(),
EducationLevel: user.EducationLevel, EducationLevel: user.EducationLevel,
Country: user.Country, Country: user.Country,
Region: user.Region, Region: user.Region,
@ -1343,6 +1393,10 @@ func (h *Handler) AdminProfile(c *fiber.Ctx) error {
CreatedAt: user.CreatedAt, CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt, 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) 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, FirstName: user.FirstName,
LastName: user.LastName, LastName: user.LastName,
// UserName: user.UserName, // 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, PhoneNumber: user.PhoneNumber,
Role: user.Role, Role: user.Role,
AgeGroup: user.AgeGroup, AgeGroup: user.AgeGroup,
@ -1526,9 +1586,15 @@ func (h *Handler) GetUserByID(c *fiber.Ctx) error {
// } // }
res := domain.UserProfileResponse{ res := domain.UserProfileResponse{
ID: user.ID, ID: user.ID,
FirstName: user.FirstName, FirstName: user.FirstName,
LastName: user.LastName, LastName: user.LastName,
BirthDay: func() string {
if !user.BirthDay.IsZero() {
return user.BirthDay.Format("2006-01-02")
}
return ""
}(),
LearningGoal: user.LearningGoal, LearningGoal: user.LearningGoal,
LanguageGoal: user.LanguageGoal, LanguageGoal: user.LanguageGoal,
LanguageChallange: user.LanguageChallange, LanguageChallange: user.LanguageChallange,
@ -1605,3 +1671,133 @@ type UpdateUserSuspendRes struct {
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
Suspended bool `json:"suspended"` 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 // Get S
groupV1.Get("/tenant", a.authMiddleware, h.GetTenantSlugByToken) 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/admin-login", h.LoginAdmin)
groupV1.Post("/auth/super-login", h.LoginSuper) groupV1.Post("/auth/super-login", h.LoginSuper)
groupV1.Post("/auth/refresh", h.RefreshToken) 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.Post("/auth/logout", a.authMiddleware, h.LogOutuser)
groupV1.Get("/auth/test", a.authMiddleware, func(c *fiber.Ctx) error { groupV1.Get("/auth/test", a.authMiddleware, func(c *fiber.Ctx) error {
userID, ok := c.Locals("user_id").(int64) 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.Patch("/issues/:issue_id/status", a.authMiddleware, a.OnlyAdminAndAbove, h.UpdateIssueStatus)
// groupV1.Delete("/issues/:issue_id", a.authMiddleware, a.OnlyAdminAndAbove, h.DeleteIssue) // groupV1.Delete("/issues/:issue_id", a.authMiddleware, a.OnlyAdminAndAbove, h.DeleteIssue)
// Device Token Registration
groupV1.Post("/devices/register", a.authMiddleware, h.RegisterDeviceToken)
// Settings // Settings
groupV1.Get("/settings", a.authMiddleware, a.SuperAdminOnly, h.GetGlobalSettingList) groupV1.Get("/settings", a.authMiddleware, a.SuperAdminOnly, h.GetGlobalSettingList)
groupV1.Get("/settings/:key", a.authMiddleware, a.SuperAdminOnly, h.GetGlobalSettingByKey) groupV1.Get("/settings/:key", a.authMiddleware, a.SuperAdminOnly, h.GetGlobalSettingByKey)

View File

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