Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3,908 changes: 2,014 additions & 1,894 deletions package-lock.json

Large diffs are not rendered by default.

35 changes: 35 additions & 0 deletions src/db/migrations/021_add_tags_and_synonyms.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { PoolClient } from "pg";
import { Migration } from "../migrationRunner.js";

export const migration: Migration = {
id: "021",
name: "add_tags_and_synonyms",

async up(client: PoolClient): Promise<void> {
await client.query(`
ALTER TABLE slots
ADD COLUMN tags VARCHAR(100)[] NOT NULL DEFAULT '{}'
`);

await client.query(`
CREATE INDEX idx_slots_tags ON slots USING gin (tags)
`);

await client.query(`
CREATE TABLE search_synonyms (
id SERIAL PRIMARY KEY,
word VARCHAR(100) UNIQUE NOT NULL,
synonyms VARCHAR(100)[] NOT NULL
)
`);
},

async down(client: PoolClient): Promise<void> {
await client.query(`DROP TABLE IF EXISTS search_synonyms`);
await client.query(`DROP INDEX IF EXISTS idx_slots_tags`);
await client.query(`
ALTER TABLE slots
DROP COLUMN IF EXISTS tags
`);
},
};
2 changes: 2 additions & 0 deletions src/db/migrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { migration as migration014b } from "./014_create_reputation_events.js";
import { migration as migration015 } from "./015_create_reputation_snapshots.js";
import { migration as migration016 } from "./016_add_grace_window_config.js";
import { migration as migration017 } from "./018_add_partner_token_quotas.js";
import { migration as migration021 } from "./021_add_tags_and_synonyms.js";

export const migrations: Migration[] = [
migration001,
Expand All @@ -58,6 +59,7 @@ export const migrations: Migration[] = [
migration015,
migration016,
migration017,
migration021,
];

// ─── Duplicate-ID guard ───────────────────────────────────────────────────────
Expand Down
158 changes: 158 additions & 0 deletions src/routes/__tests__/admin.synonyms.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { describe, it, expect, beforeEach, jest } from "@jest/globals";
import express from "express";
import request from "supertest";
import adminRouter from "../admin.js";
import { pool } from "../../db/pool.js";

const ADMIN_TOKEN = "synonym-test-admin-token";
process.env.CHRONOPAY_ADMIN_TOKEN = ADMIN_TOKEN;

function makeApp() {
const app = express();
app.use(express.json());
app.use("/api/v1/admin", adminRouter);
return app;
}

describe("Admin Synonym Registry Routes", () => {
let app: express.Application;
let agent: any;
let mockSynonyms: any[] = [];
let querySpy: any;

beforeEach(() => {
mockSynonyms = [
{ id: 1, word: "plumbing", synonyms: ["pipe", "drain"] }
];

app = makeApp();
agent = request(app);

if (querySpy) {
querySpy.mockRestore();
}

querySpy = jest.spyOn(pool, "query").mockImplementation(async (sql: string, params: any[] = []): Promise<any> => {
if (sql.includes("SELECT id, word, synonyms FROM search_synonyms ORDER BY id ASC")) {
return { rows: [...mockSynonyms] };
}
if (sql.includes("SELECT id, word, synonyms FROM search_synonyms WHERE id = $1")) {
const record = mockSynonyms.find((r) => r.id === params[0]);
return { rows: record ? [record] : [] };
}
if (sql.includes("INSERT INTO search_synonyms")) {
const record = { id: 2, word: params[0], synonyms: params[1] };
mockSynonyms.push(record);
return { rows: [record], rowCount: 1 };
}
if (sql.includes("UPDATE search_synonyms")) {
const record = mockSynonyms.find((r) => r.id === params[2]);
if (record) {
record.word = params[0];
record.synonyms = params[1];
return { rows: [record], rowCount: 1 };
}
return { rows: [], rowCount: 0 };
}
if (sql.includes("DELETE FROM search_synonyms")) {
const idx = mockSynonyms.findIndex((r) => r.id === params[0]);
if (idx !== -1) {
mockSynonyms.splice(idx, 1);
return { rows: [], rowCount: 1 };
}
return { rows: [], rowCount: 0 };
}
return { rows: [] };
});
});

describe("GET /api/v1/admin/synonyms", () => {
it("requires admin token header", async () => {
const res = await agent.get("/api/v1/admin/synonyms");
expect(res.status).toBe(401);
});

it("returns synonyms list when authorized", async () => {
const res = await agent
.get("/api/v1/admin/synonyms")
.set("x-chronopay-admin-token", ADMIN_TOKEN);

expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.synonyms).toHaveLength(1);
expect(res.body.synonyms[0].word).toBe("plumbing");
});
});

describe("POST /api/v1/admin/synonyms", () => {
it("requires admin token", async () => {
const res = await agent
.post("/api/v1/admin/synonyms")
.send({ word: "cleaning", synonyms: ["wash"] });
expect(res.status).toBe(401);
});

it("creates a synonym mapping", async () => {
const res = await agent
.post("/api/v1/admin/synonyms")
.set("x-chronopay-admin-token", ADMIN_TOKEN)
.send({ word: "cleaning", synonyms: ["wash", "sweep"] });

expect(res.status).toBe(201);
expect(res.body.success).toBe(true);
expect(res.body.synonym.word).toBe("cleaning");
expect(res.body.synonym.synonyms).toEqual(["wash", "sweep"]);
});

it("validates bad input schema", async () => {
const res = await agent
.post("/api/v1/admin/synonyms")
.set("x-chronopay-admin-token", ADMIN_TOKEN)
.send({ word: "", synonyms: [] });

expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
});
});

describe("PUT /api/v1/admin/synonyms/:id", () => {
it("updates synonym mapping", async () => {
const res = await agent
.put("/api/v1/admin/synonyms/1")
.set("x-chronopay-admin-token", ADMIN_TOKEN)
.send({ word: "plumbing", synonyms: ["pipe", "leak"] });

expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.synonym.synonyms).toEqual(["pipe", "leak"]);
});

it("returns 404 if not found", async () => {
const res = await agent
.put("/api/v1/admin/synonyms/999")
.set("x-chronopay-admin-token", ADMIN_TOKEN)
.send({ word: "plumbing" });

expect(res.status).toBe(404);
});
});

describe("DELETE /api/v1/admin/synonyms/:id", () => {
it("deletes synonym mapping", async () => {
const res = await agent
.delete("/api/v1/admin/synonyms/1")
.set("x-chronopay-admin-token", ADMIN_TOKEN);

expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});

it("returns 404 if not found", async () => {
const res = await agent
.delete("/api/v1/admin/synonyms/999")
.set("x-chronopay-admin-token", ADMIN_TOKEN);

expect(res.status).toBe(404);
});
});
});
113 changes: 113 additions & 0 deletions src/routes/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import { Router, type Request, type Response } from "express";
import { requireAdminToken } from "../middleware/authorization.js";
import { auditExportService } from "../services/auditExportService.js";
import { RefundService } from "../services/refund.js";
import { pool } from "../db/pool.js";
import { SynonymService } from "../services/synonymService.js";
import { z } from "zod";
import { requireAuthenticatedActor } from "../middleware/auth.js";
import { defaultAuditLogger } from "../services/auditLogger.js";
import {
Expand Down Expand Up @@ -2167,4 +2170,114 @@ router.get(
},
);

const SynonymPayloadSchema = z.object({
word: z.string().trim().min(1, "word cannot be empty").max(100, "word too long"),
synonyms: z.array(z.string().trim().min(1, "synonym cannot be empty").max(100, "synonym too long")).min(1, "must provide at least one synonym"),
});

const UpdateSynonymPayloadSchema = SynonymPayloadSchema.partial();

/**
* @route GET /api/v1/admin/synonyms
* @desc Get all synonym records from registry.
* @access Private (admin token only)
*/
router.get(
"/synonyms",
requireAdminToken,
async (req: Request, res: Response) => {
try {
const synonymService = new SynonymService(pool);
const list = await synonymService.getAll();
return res.status(200).json({ success: true, synonyms: list });
} catch (err: any) {
return res.status(500).json({ success: false, error: err.message ?? "Failed to fetch synonyms" });
}
}
);

/**
* @route POST /api/v1/admin/synonyms
* @desc Create a new synonym mapping.
* @access Private (admin token only)
*/
router.post(
"/synonyms",
requireAdminToken,
async (req: Request, res: Response) => {
try {
const validated = SynonymPayloadSchema.parse(req.body);
const synonymService = new SynonymService(pool);

const record = await synonymService.create(validated.word, validated.synonyms);
return res.status(201).json({ success: true, synonym: record });
} catch (err: any) {
if (err instanceof z.ZodError) {
return res.status(400).json({ success: false, error: "Validation failed", details: err.errors });
}
if (err.message?.includes("unique")) {
return res.status(409).json({ success: false, error: "Synonym entry already exists for this word" });
}
return res.status(500).json({ success: false, error: err.message ?? "Failed to create synonym" });
}
}
);

/**
* @route PUT /api/v1/admin/synonyms/:id
* @desc Update an existing synonym mapping.
* @access Private (admin token only)
*/
router.put(
"/synonyms/:id",
requireAdminToken,
async (req: Request, res: Response) => {
try {
const id = parseInt(req.params.id, 10);
if (isNaN(id)) {
return res.status(400).json({ success: false, error: "Invalid id" });
}
const validated = UpdateSynonymPayloadSchema.parse(req.body);
const synonymService = new SynonymService(pool);

const record = await synonymService.update(id, validated.word, validated.synonyms);
if (!record) {
return res.status(404).json({ success: false, error: "Synonym mapping not found" });
}
return res.status(200).json({ success: true, synonym: record });
} catch (err: any) {
if (err instanceof z.ZodError) {
return res.status(400).json({ success: false, error: "Validation failed", details: err.errors });
}
return res.status(500).json({ success: false, error: err.message ?? "Failed to update synonym" });
}
}
);

/**
* @route DELETE /api/v1/admin/synonyms/:id
* @desc Delete a synonym mapping.
* @access Private (admin token only)
*/
router.delete(
"/synonyms/:id",
requireAdminToken,
async (req: Request, res: Response) => {
try {
const id = parseInt(req.params.id, 10);
if (isNaN(id)) {
return res.status(400).json({ success: false, error: "Invalid id" });
}
const synonymService = new SynonymService(pool);
const deleted = await synonymService.delete(id);
if (!deleted) {
return res.status(404).json({ success: false, error: "Synonym mapping not found" });
}
return res.status(200).json({ success: true, message: "Synonym mapping deleted successfully" });
} catch (err: any) {
return res.status(500).json({ success: false, error: err.message ?? "Failed to delete synonym" });
}
}
);

export default router;
Loading
Loading