diff --git a/db/migrations/000001_yimaru.up.sql b/db/migrations/000001_yimaru.up.sql index b0a25f2..488d669 100644 --- a/db/migrations/000001_yimaru.up.sql +++ b/db/migrations/000001_yimaru.up.sql @@ -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, diff --git a/db/query/device.sql b/db/query/device.sql new file mode 100644 index 0000000..22a0337 --- /dev/null +++ b/db/query/device.sql @@ -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'); \ No newline at end of file diff --git a/docs/docs.go b/docs/docs.go index e11741a..085447b 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -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": { diff --git a/docs/swagger.json b/docs/swagger.json index 66ce865..2bc0563 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -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": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index c6a1336..63597aa 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -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: diff --git a/gen/db/device.sql.go b/gen/db/device.sql.go new file mode 100644 index 0000000..b75d8ff --- /dev/null +++ b/gen/db/device.sql.go @@ -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 +} diff --git a/gen/db/models.go b/gen/db/models.go index 1fc1f9a..4a966c6 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -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"` diff --git a/go.mod b/go.mod index 88b4737..06c0da9 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 888406f..4482a46 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/config/config.go b/internal/config/config.go index b7ebd37..4d84067 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 } diff --git a/internal/domain/common.go b/internal/domain/common.go index de1c1bf..b428847 100644 --- a/internal/domain/common.go +++ b/internal/domain/common.go @@ -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"` diff --git a/internal/domain/user.go b/internal/domain/user.go index 4a49212..ae99e4a 100644 --- a/internal/domain/user.go +++ b/internal/domain/user.go @@ -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"` diff --git a/internal/ports/user.go b/internal/ports/user.go index 3024a8a..15da48d 100644 --- a/internal/ports/user.go +++ b/internal/ports/user.go @@ -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 diff --git a/internal/repository/auth.go b/internal/repository/auth.go index 1c874bf..e6c5986 100644 --- a/internal/repository/auth.go +++ b/internal/repository/auth.go @@ -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, diff --git a/internal/repository/user.go b/internal/repository/user.go index dff0e22..a4f179c 100644 --- a/internal/repository/user.go +++ b/internal/repository/user.go @@ -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, diff --git a/internal/services/notification/service.go b/internal/services/notification/service.go index c1817a0..0dab1dd 100644 --- a/internal/services/notification/service.go +++ b/internal/services/notification/service.go @@ -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() diff --git a/internal/services/user/interface.go b/internal/services/user/interface.go index 152a027..254e534 100644 --- a/internal/services/user/interface.go +++ b/internal/services/user/interface.go @@ -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 diff --git a/internal/services/user/user.go b/internal/services/user/user.go index fe4827c..df253f7 100644 --- a/internal/services/user/user.go +++ b/internal/services/user/user.go @@ -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) +} diff --git a/internal/web_server/handlers/notification_handler.go b/internal/web_server/handlers/notification_handler.go index f61d0b1..bf4e27f 100644 --- a/internal/web_server/handlers/notification_handler.go +++ b/internal/web_server/handlers/notification_handler.go @@ -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, + }) +} diff --git a/internal/web_server/handlers/user.go b/internal/web_server/handlers/user.go index a925286..56fa31a 100644 --- a/internal/web_server/handlers/user.go +++ b/internal/web_server/handlers/user.go @@ -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) +} diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 066ae74..f9c075d 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -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) diff --git a/new.env b/new.env index 996b69e..628190a 100755 --- a/new.env +++ b/new.env @@ -58,3 +58,5 @@ TWILIO_SENDER_PHONE_NUMBER=0912345678 # Redis REDIS_ADDR=redis:6379 +# Firebase Cloud Messaging +