diff --git a/packages/shared/package.json b/packages/shared/package.json index 0743d79..5685d75 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -9,6 +9,10 @@ "./phone": { "types": "./phone.d.ts", "default": "./phone.js" + }, + "./units": { + "types": "./units.d.ts", + "default": "./units.js" } }, "scripts": { diff --git a/server/migrations/001_add_profile_fields.sql b/server/migrations/001_add_profile_fields.sql new file mode 100644 index 0000000..b7870f7 --- /dev/null +++ b/server/migrations/001_add_profile_fields.sql @@ -0,0 +1,14 @@ +ALTER TABLE users + ADD COLUMN IF NOT EXISTS alias VARCHAR(30), + ADD COLUMN IF NOT EXISTS notifications_enabled BOOLEAN NOT NULL DEFAULT TRUE, + ADD COLUMN IF NOT EXISTS kyc_status VARCHAR(20) NOT NULL DEFAULT 'unverified', + ADD COLUMN IF NOT EXISTS virtual_account_number VARCHAR(64); + +ALTER TABLE users + DROP CONSTRAINT IF EXISTS users_kyc_status_check; + +ALTER TABLE users + ADD CONSTRAINT users_kyc_status_check + CHECK (kyc_status IN ('unverified', 'pending', 'verified')); + +CREATE INDEX IF NOT EXISTS users_kyc_status_idx ON users (kyc_status); diff --git a/server/src/__tests__/trade-lifecycle.test.ts b/server/src/__tests__/trade-lifecycle.test.ts index de0de20..5775440 100644 --- a/server/src/__tests__/trade-lifecycle.test.ts +++ b/server/src/__tests__/trade-lifecycle.test.ts @@ -12,7 +12,7 @@ import request from "supertest"; import jwt from "jsonwebtoken"; import { v4 as uuidv4 } from "uuid"; import app from "../index"; -import pool from "../db/pool"; +import { pool } from "../db/pool"; // Mock the entire stellar service jest.mock("../services/stellar", () => { diff --git a/server/src/db.ts b/server/src/db.ts new file mode 100644 index 0000000..ed2c291 --- /dev/null +++ b/server/src/db.ts @@ -0,0 +1,2 @@ +export { pool, query } from "./db/pool"; +export { pool as default } from "./db/pool"; diff --git a/server/src/openapi.ts b/server/src/openapi.ts index a23bbd0..0568f93 100644 --- a/server/src/openapi.ts +++ b/server/src/openapi.ts @@ -420,6 +420,9 @@ export const openApiDocument = { "maskedPhone", "createdAt", "totalTradesCompleted", + "role", + "kycStatus", + "virtualAccountNumber", "stellarPublicKey", ], properties: { @@ -430,6 +433,8 @@ export const openApiDocument = { }, createdAt: { type: "string", format: "date-time" }, totalTradesCompleted: { type: "integer", example: 5 }, + role: { type: "string", example: "user" }, + virtualAccountNumber: { type: "string", example: "0123456789" }, stellarPublicKey: { type: "string", example: "GABC1234..." }, kycStatus: { type: "string", @@ -1402,6 +1407,23 @@ export const openApiDocument = { // ------------------------------------------------------------------ Profile "/api/v1/profile": { + patch: { + tags: ["Profile"], + summary: "Update own profile", + security: [{ bearerAuth: [] }], + requestBody: { + required: true, + content: { "application/json": { schema: { + type: "object", + properties: { + alias: { type: "string", maxLength: 30, pattern: "^[A-Za-z0-9]+$" }, + notificationsEnabled: { type: "boolean" }, + }, + additionalProperties: false, + } } }, + }, + responses: { "200": { description: "Profile updated." }, "400": { $ref: "#/components/responses/ErrorResponse" }, "401": { $ref: "#/components/responses/Unauthorized" }, "422": { $ref: "#/components/responses/UnprocessableEntity" } }, + }, get: { tags: ["Profile"], summary: "Get own profile", @@ -1607,6 +1629,16 @@ export const openApiDocument = { }, // ------------------------------------------------------------------ Admin + "/api/v1/admin/users/{id}/kyc": { + patch: { + tags: ["Admin"], + summary: "Update user KYC status", + security: [{ bearerAuth: [] }], + parameters: [{ name: "id", in: "path", required: true, schema: { type: "string", format: "uuid" } }], + requestBody: { required: true, content: { "application/json": { schema: { type: "object", required: ["status"], properties: { status: { type: "string", enum: ["unverified", "pending", "verified"] } } } } } }, + responses: { "200": { description: "KYC status updated." }, "401": { $ref: "#/components/responses/Unauthorized" }, "403": { $ref: "#/components/responses/Forbidden" }, "404": { $ref: "#/components/responses/NotFound" } }, + }, + }, "/api/v1/admin/queues": { get: { tags: ["Admin"], diff --git a/server/src/routes/admin.test.ts b/server/src/routes/admin.test.ts new file mode 100644 index 0000000..71595a0 --- /dev/null +++ b/server/src/routes/admin.test.ts @@ -0,0 +1,96 @@ +/** + * admin.test.ts + * + * Tests for the admin-only KYC status endpoint + * (PATCH /api/v1/admin/users/:id/kyc). + */ + +import request from "supertest"; +import express from "express"; +import jwt from "jsonwebtoken"; + +const JWT_SECRET = "test-secret-key-1234567890"; +process.env["JWT_SECRET"] = JWT_SECRET; + +// Mock database pool +const mockQuery = jest.fn(); +jest.mock("../db", () => ({ + __esModule: true, + default: { + query: (...args: any[]) => mockQuery(...args), + connect: jest.fn(), + }, +})); + +import adminRouter from "./admin"; + +const app = express(); +app.use(express.json()); +app.use("/api/v1/admin", adminRouter); + +describe("PATCH /api/v1/admin/users/:id/kyc", () => { + const userId = "11111111-1111-4111-8111-111111111111"; + const token = jwt.sign( + { sub: userId, stellarPublicKey: "GTEST" }, + JWT_SECRET, + { expiresIn: "1h" } + ); + + beforeEach(() => { + mockQuery.mockReset(); + }); + + it("returns 401 when unauthenticated", async () => { + const res = await request(app).patch(`/api/v1/admin/users/${userId}/kyc`); + expect(res.status).toBe(401); + }); + + it("requires admin role", async () => { + // 0th query: authenticate's token-revocation check (token_version lookup). + mockQuery.mockResolvedValueOnce({ rows: [{ token_version: 1 }] }); + + // 1st query: authorize's role lookup. + mockQuery.mockResolvedValueOnce({ rows: [{ role: "user" }] }); + + const res = await request(app) + .patch(`/api/v1/admin/users/${userId}/kyc`) + .set("Authorization", `Bearer ${token}`) + .send({ status: "verified" }); + + expect(res.status).toBe(403); + expect(res.body.error).toBe("Admin access required"); + }); + + it("validates KYC status", async () => { + mockQuery.mockResolvedValueOnce({ rows: [{ token_version: 1 }] }); + mockQuery.mockResolvedValueOnce({ rows: [{ role: "admin" }] }); + + const res = await request(app) + .patch(`/api/v1/admin/users/${userId}/kyc`) + .set("Authorization", `Bearer ${token}`) + .send({ status: "approved" }); + + expect(res.status).toBe(422); + expect(res.body.error).toBe("Invalid KYC status"); + }); + + it("updates a user's KYC status", async () => { + mockQuery.mockResolvedValueOnce({ rows: [{ token_version: 1 }] }); + mockQuery.mockResolvedValueOnce({ rows: [{ role: "admin" }] }); + mockQuery.mockResolvedValueOnce({ + rows: [{ id: userId, kyc_status: "verified" }], + }); + + const res = await request(app) + .patch(`/api/v1/admin/users/${userId}/kyc`) + .set("Authorization", `Bearer ${token}`) + .send({ status: "verified" }); + + expect(res.status).toBe(200); + expect(res.body.data.kyc_status).toBe("verified"); + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining("UPDATE users SET kyc_status"), + ["verified", userId] + ); + }); +}); diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index e1e0429..36b1512 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -19,6 +19,7 @@ import { authorize } from "../middleware/authorize"; import { validate } from "../middleware/validate"; import { QueueService } from "../jobs"; import { SseEmitter } from "../services/sseEmitter"; +import { z } from "zod"; import pool from "../db"; import { resolveDispute } from "../services/stellar"; import { @@ -493,4 +494,41 @@ router.post( } ); +// --------------------------------------------------------------------------- +// PATCH /api/v1/admin/users/:id/kyc (admin only) +// --------------------------------------------------------------------------- + +const kycSchema = z.object({ + status: z.enum(["unverified", "pending", "verified"]), +}); + +router.patch( + "/users/:id/kyc", + authenticate, + authorize("admin"), + async (req, res) => { + const parsed = kycSchema.safeParse(req.body); + if (!parsed.success) { + res.status(422).json({ + error: "Invalid KYC status", + details: parsed.error.flatten(), + }); + return; + } + + const { rows } = await pool.query( + `UPDATE users SET kyc_status = $1, updated_at = NOW() + WHERE id = $2 + RETURNING id, kyc_status`, + [parsed.data.status, req.params.id] + ); + + if (!rows.length) { + res.status(404).json({ error: "User not found" }); + return; + } + res.status(200).json({ data: rows[0] }); + } +); + export default router; diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index 3e6a300..c5f349a 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -46,4 +46,5 @@ export function registerRoutes(app: Express): void { // GET /api/docs.json — always available // GET /api/docs — Swagger UI (disabled in production unless ENABLE_API_DOCS=true) app.use('/api', docsRouter); + app.use('/api/v1', docsRouter); } diff --git a/server/src/routes/profile.test.ts b/server/src/routes/profile.test.ts index 30bc038..15c584d 100644 --- a/server/src/routes/profile.test.ts +++ b/server/src/routes/profile.test.ts @@ -1,7 +1,9 @@ /** * profile.test.ts * - * Tests for GET /api/v1/profile endpoint including referralCode verification. + * Tests for GET /api/v1/profile including referralCode verification, + * plus the PATCH /api/v1/profile update and trade-history query validation + * added in issue #74. */ import request from "supertest"; @@ -52,7 +54,9 @@ describe("GET /api/v1/profile", () => { phone: "+2348012345678", created_at: "2026-01-01T00:00:00.000Z", stellar_public_key: "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H", + role: "user", kyc_status: "verified", + virtual_account_number: "0123456789", referral_code: "AIR77REF", }, ], @@ -74,6 +78,9 @@ describe("GET /api/v1/profile", () => { expect(typeof res.body.data.referralCode).toBe("string"); expect(res.body.data.referralCode.length).toBeGreaterThan(0); expect(res.body.data.totalTradesCompleted).toBe(12); + expect(res.body.data.role).toBe("user"); + expect(res.body.data.kycStatus).toBe("verified"); + expect(res.body.data.virtualAccountNumber).toBe("0123456789"); }); it("returns 404 when user is not found in database", async () => { @@ -91,3 +98,114 @@ describe("GET /api/v1/profile", () => { expect(res.body.error).toBe("User not found"); }); }); + +describe("PATCH /api/v1/profile", () => { + const userId = "11111111-1111-4111-8111-111111111111"; + const token = jwt.sign( + { sub: userId, stellarPublicKey: "GTEST" }, + JWT_SECRET, + { expiresIn: "1h" } + ); + + beforeEach(() => { + mockQuery.mockReset(); + }); + + it("returns 401 when unauthenticated", async () => { + const res = await request(app).patch("/api/v1/profile"); + expect(res.status).toBe(401); + }); + + it("rejects invalid profile updates", async () => { + // 0th query: authenticate's token-revocation check (token_version lookup). + mockQuery.mockResolvedValueOnce({ rows: [{ token_version: 1 }] }); + + const res = await request(app) + .patch("/api/v1/profile") + .set("Authorization", `Bearer ${token}`) + .send({ alias: "not valid!" }); + + expect(res.status).toBe(422); + }); + + it("updates only supplied profile fields", async () => { + // 0th query: authenticate's token-revocation check (token_version lookup). + mockQuery.mockResolvedValueOnce({ rows: [{ token_version: 1 }] }); + + // 1st query: the profile UPDATE itself. + mockQuery.mockResolvedValueOnce({ + rows: [{ id: userId, alias: "Alice", notifications_enabled: false }], + }); + + const res = await request(app) + .patch("/api/v1/profile") + .set("Authorization", `Bearer ${token}`) + .send({ alias: "Alice", notificationsEnabled: false }); + + expect(res.status).toBe(200); + expect(res.body.data.alias).toBe("Alice"); + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining("UPDATE users SET"), + ["Alice", false, userId] + ); + }); + + it("returns 400 when no profile field is supplied", async () => { + // 0th query: authenticate's token-revocation check (token_version lookup). + mockQuery.mockResolvedValueOnce({ rows: [{ token_version: 1 }] }); + + const res = await request(app) + .patch("/api/v1/profile") + .set("Authorization", `Bearer ${token}`) + .send({}); + + expect(res.status).toBe(400); + }); +}); + +describe("GET /api/v1/profile/trades", () => { + const userId = "11111111-1111-4111-8111-111111111111"; + const token = jwt.sign( + { sub: userId, stellarPublicKey: "GTEST" }, + JWT_SECRET, + { expiresIn: "1h" } + ); + + beforeEach(() => { + mockQuery.mockReset(); + }); + + it("rejects an invalid trade-history status filter", async () => { + // 0th query: authenticate's token-revocation check (token_version lookup). + mockQuery.mockResolvedValueOnce({ rows: [{ token_version: 1 }] }); + + const res = await request(app) + .get("/api/v1/profile/trades?status=unknown") + .set("Authorization", `Bearer ${token}`); + + expect(res.status).toBe(400); + expect(res.body.error).toBe("Invalid query parameters"); + }); + + it("returns paginated trade history", async () => { + // 0th query: authenticate's token-revocation check (token_version lookup). + mockQuery.mockResolvedValueOnce({ rows: [{ token_version: 1 }] }); + + // 1st query: trades, 2nd query: total count. + mockQuery.mockResolvedValueOnce({ rows: [{ id: "trade-1", status: "Active" }] }); + mockQuery.mockResolvedValueOnce({ rows: [{ count: "1" }] }); + + const res = await request(app) + .get("/api/v1/profile/trades?page=1&limit=10&status=Active") + .set("Authorization", `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.pagination).toEqual({ + page: 1, + limit: 10, + total: 1, + totalPages: 1, + }); + }); +}); diff --git a/server/src/routes/profile.ts b/server/src/routes/profile.ts index a579816..365844f 100644 --- a/server/src/routes/profile.ts +++ b/server/src/routes/profile.ts @@ -1,7 +1,9 @@ import { Router } from "express"; import { createHash } from "crypto"; +import { z } from "zod"; import pool from "../db"; import { authenticate, AuthenticatedRequest } from "../middleware/authenticate"; +import { validate } from "../middleware/validate"; import { QueueService } from "../jobs"; import type { TradeOffer, TradeStatus } from "../types/trade"; import logger from "../utils/logger"; @@ -105,10 +107,13 @@ router.get( phone: string; created_at: string; stellar_public_key: string | null; - kyc_status: string | null; + role: string; + kyc_status: "unverified" | "pending" | "verified"; + virtual_account_number: string | null; referral_code: string | null; }>( - `SELECT id, phone, created_at, stellar_public_key, kyc_status, referral_code + `SELECT id, phone, created_at, stellar_public_key, role, kyc_status, + virtual_account_number, referral_code FROM users WHERE id = $1 LIMIT 1`, @@ -137,14 +142,60 @@ router.get( maskedPhone: maskPhone(user.phone), createdAt: user.created_at, totalTradesCompleted, - stellarPublicKey: user.stellar_public_key ?? "", + role: user.role, kycStatus: user.kyc_status ?? "unverified", + virtualAccountNumber: user.virtual_account_number ?? "", + stellarPublicKey: user.stellar_public_key ?? "", referralCode: user.referral_code ?? "", }, }); } ); +// --------------------------------------------------------------------------- +// PATCH /api/v1/profile (authenticated) +// --------------------------------------------------------------------------- + +const profileUpdateSchema = z.object({ + alias: z.string().max(30).regex(/^[a-zA-Z0-9]+$/).optional(), + notificationsEnabled: z.boolean().optional(), +}).strict(); + +router.patch( + "/", + authenticate, + validate(profileUpdateSchema), + async (req, res) => { + const { sub: userId } = (req as AuthenticatedRequest).user; + const updates = req.body as z.infer; + const fields: string[] = []; + const values: unknown[] = []; + + if (updates.alias !== undefined) { + fields.push(`alias = $${values.length + 1}`); + values.push(updates.alias); + } + if (updates.notificationsEnabled !== undefined) { + fields.push(`notifications_enabled = $${values.length + 1}`); + values.push(updates.notificationsEnabled); + } + + if (!fields.length) { + res.status(400).json({ error: "At least one profile field is required" }); + return; + } + + values.push(userId); + const { rows } = await pool.query( + `UPDATE users SET ${fields.join(", ")}, updated_at = NOW() + WHERE id = $${values.length} + RETURNING id, alias, notifications_enabled`, + values + ); + res.status(200).json({ data: rows[0] }); + } +); + // --------------------------------------------------------------------------- // GET /api/v1/profile/trades (authenticated) // --------------------------------------------------------------------------- @@ -165,19 +216,24 @@ router.get( async (req, res) => { const { sub: userId } = (req as AuthenticatedRequest).user; - const rawPage = parseInt(String(req.query["page"] ?? "1"), 10); - const rawLimit = parseInt(String(req.query["limit"] ?? "10"), 10); - const rawStatus = String(req.query["status"] ?? "All"); + const parsedPagination = z.object({ + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(50).default(10), + status: z.enum(["All", ...ALLOWED_STATUSES] as [string, ...string[]]).default("All"), + }).safeParse(req.query); + + if (!parsedPagination.success) { + res.status(400).json({ + error: "Invalid query parameters", + details: parsedPagination.error.flatten(), + }); + return; + } - const page = isNaN(rawPage) || rawPage < 1 ? 1 : rawPage; - const limit = isNaN(rawLimit) || rawLimit < 1 ? 10 - : rawLimit > 50 ? 50 - : rawLimit; + const { page, limit, status: rawStatus } = parsedPagination.data; const offset = (page - 1) * limit; - const filterByStatus = - rawStatus !== "All" && - (ALLOWED_STATUSES as string[]).includes(rawStatus); + const filterByStatus = rawStatus !== "All"; const baseParams: (string | number)[] = [userId, userId]; const baseWhere = `(seller_id = $1 OR buyer_id = $2)`;