@wopr-network/platform-core 1.0.4 → 1.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +5 -0
- package/dist/account/deletion-executor-repository.d.ts +17 -0
- package/dist/account/deletion-executor-repository.js +41 -0
- package/dist/account/deletion-executor-repository.test.d.ts +1 -0
- package/dist/account/deletion-executor-repository.test.js +89 -0
- package/dist/account/deletion-repository.d.ts +19 -0
- package/dist/account/deletion-repository.js +62 -0
- package/dist/account/deletion-repository.test.d.ts +1 -0
- package/dist/account/deletion-repository.test.js +85 -0
- package/dist/account/export-repository.d.ts +21 -0
- package/dist/account/export-repository.js +77 -0
- package/dist/account/export-repository.test.d.ts +1 -0
- package/dist/account/export-repository.test.js +109 -0
- package/dist/account/index.d.ts +4 -0
- package/dist/account/index.js +3 -0
- package/dist/account/repository-types.d.ts +38 -0
- package/dist/account/repository-types.js +4 -0
- package/dist/auth/auth-route-handler.test.d.ts +1 -0
- package/dist/auth/auth-route-handler.test.js +191 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/metering/emitter.d.ts +2 -2
- package/dist/metering/emitter.js +4 -4
- package/dist/metering/emitter.test.js +7 -7
- package/dist/metering/metering.test.js +73 -73
- package/dist/metering/wal.d.ts +6 -4
- package/dist/metering/wal.js +14 -10
- package/dist/metering/wal.test.js +21 -0
- package/dist/security/redirect-allowlist.js +20 -1
- package/dist/security/redirect-allowlist.test.js +34 -0
- package/package.json +1 -1
- package/src/account/deletion-executor-repository.test.ts +109 -0
- package/src/account/deletion-executor-repository.ts +58 -0
- package/src/account/deletion-repository.test.ts +103 -0
- package/src/account/deletion-repository.ts +82 -0
- package/src/account/export-repository.test.ts +135 -0
- package/src/account/export-repository.ts +101 -0
- package/src/account/index.ts +14 -0
- package/src/account/repository-types.ts +46 -0
- package/src/auth/auth-route-handler.test.ts +243 -0
- package/src/index.ts +3 -0
- package/src/metering/emitter.test.ts +7 -7
- package/src/metering/emitter.ts +5 -5
- package/src/metering/metering.test.ts +75 -73
- package/src/metering/wal.test.ts +26 -0
- package/src/metering/wal.ts +14 -10
- package/src/security/redirect-allowlist.test.ts +41 -0
- package/src/security/redirect-allowlist.ts +19 -1
package/.env.example
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { PlatformDb } from "../db/index.js";
|
|
2
|
+
import type { DeletionRequestRow } from "./repository-types.js";
|
|
3
|
+
export interface IDeletionExecutorRepository {
|
|
4
|
+
/** Find all pending requests whose deleteAfter is <= now. */
|
|
5
|
+
findRipe(now: string): Promise<DeletionRequestRow[]>;
|
|
6
|
+
/** Mark a request as completed with a deletion summary. */
|
|
7
|
+
markCompleted(id: string, deletionSummary: string): Promise<boolean>;
|
|
8
|
+
/** Find the active (pending) deletion request for a tenant, if any. */
|
|
9
|
+
findPendingByTenant(tenantId: string): Promise<DeletionRequestRow | null>;
|
|
10
|
+
}
|
|
11
|
+
export declare class DrizzleDeletionExecutorRepository implements IDeletionExecutorRepository {
|
|
12
|
+
private readonly db;
|
|
13
|
+
constructor(db: PlatformDb);
|
|
14
|
+
findRipe(now: string): Promise<DeletionRequestRow[]>;
|
|
15
|
+
markCompleted(id: string, deletionSummary: string): Promise<boolean>;
|
|
16
|
+
findPendingByTenant(tenantId: string): Promise<DeletionRequestRow | null>;
|
|
17
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { and, eq, lte, sql } from "drizzle-orm";
|
|
2
|
+
import { accountDeletionRequests } from "../db/schema/index.js";
|
|
3
|
+
import { toRow } from "./deletion-repository.js";
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Implementation
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
export class DrizzleDeletionExecutorRepository {
|
|
8
|
+
db;
|
|
9
|
+
constructor(db) {
|
|
10
|
+
this.db = db;
|
|
11
|
+
}
|
|
12
|
+
async findRipe(now) {
|
|
13
|
+
const rows = await this.db
|
|
14
|
+
.select()
|
|
15
|
+
.from(accountDeletionRequests)
|
|
16
|
+
.where(and(eq(accountDeletionRequests.status, "pending"), lte(accountDeletionRequests.deleteAfter, now)));
|
|
17
|
+
return rows.map(toRow);
|
|
18
|
+
}
|
|
19
|
+
async markCompleted(id, deletionSummary) {
|
|
20
|
+
const result = await this.db
|
|
21
|
+
.update(accountDeletionRequests)
|
|
22
|
+
.set({
|
|
23
|
+
status: "completed",
|
|
24
|
+
completedAt: sql `now()`,
|
|
25
|
+
deletionSummary,
|
|
26
|
+
updatedAt: sql `now()`,
|
|
27
|
+
})
|
|
28
|
+
.where(and(eq(accountDeletionRequests.id, id), eq(accountDeletionRequests.status, "pending")))
|
|
29
|
+
.returning({ id: accountDeletionRequests.id });
|
|
30
|
+
return result.length > 0;
|
|
31
|
+
}
|
|
32
|
+
async findPendingByTenant(tenantId) {
|
|
33
|
+
const rows = await this.db
|
|
34
|
+
.select()
|
|
35
|
+
.from(accountDeletionRequests)
|
|
36
|
+
.where(and(eq(accountDeletionRequests.tenantId, tenantId), eq(accountDeletionRequests.status, "pending")))
|
|
37
|
+
.limit(1);
|
|
38
|
+
const row = rows[0];
|
|
39
|
+
return row ? toRow(row) : null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createTestDb, truncateAllTables } from "../test/db.js";
|
|
3
|
+
import { DrizzleDeletionExecutorRepository } from "./deletion-executor-repository.js";
|
|
4
|
+
import { DrizzleDeletionRepository } from "./deletion-repository.js";
|
|
5
|
+
function makeRow(overrides = {}) {
|
|
6
|
+
return {
|
|
7
|
+
id: overrides.id ?? "del-001",
|
|
8
|
+
tenantId: overrides.tenantId ?? "tenant-1",
|
|
9
|
+
requestedBy: overrides.requestedBy ?? "user-1",
|
|
10
|
+
deleteAfter: overrides.deleteAfter ?? "2026-03-01T00:00:00.000Z",
|
|
11
|
+
reason: overrides.reason,
|
|
12
|
+
...overrides,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
describe("DrizzleDeletionExecutorRepository", () => {
|
|
16
|
+
let pool;
|
|
17
|
+
let db;
|
|
18
|
+
let repo;
|
|
19
|
+
let insertRepo;
|
|
20
|
+
beforeAll(async () => {
|
|
21
|
+
({ db, pool } = await createTestDb());
|
|
22
|
+
});
|
|
23
|
+
afterAll(async () => {
|
|
24
|
+
await pool.close();
|
|
25
|
+
});
|
|
26
|
+
beforeEach(async () => {
|
|
27
|
+
await truncateAllTables(pool);
|
|
28
|
+
repo = new DrizzleDeletionExecutorRepository(db);
|
|
29
|
+
insertRepo = new DrizzleDeletionRepository(db);
|
|
30
|
+
});
|
|
31
|
+
describe("findRipe", () => {
|
|
32
|
+
it("returns pending requests whose deleteAfter is <= now", async () => {
|
|
33
|
+
await insertRepo.insert(makeRow({ id: "ripe-1", deleteAfter: "2026-03-01T00:00:00.000Z" }));
|
|
34
|
+
await insertRepo.insert(makeRow({ id: "ripe-2", deleteAfter: "2026-03-05T00:00:00.000Z" }));
|
|
35
|
+
await insertRepo.insert(makeRow({ id: "future", deleteAfter: "2026-04-01T00:00:00.000Z" }));
|
|
36
|
+
const ripe = await repo.findRipe("2026-03-10T00:00:00.000Z");
|
|
37
|
+
expect(ripe).toHaveLength(2);
|
|
38
|
+
expect(ripe.map((r) => r.id).sort()).toEqual(["ripe-1", "ripe-2"]);
|
|
39
|
+
});
|
|
40
|
+
it("excludes completed and cancelled requests", async () => {
|
|
41
|
+
await insertRepo.insert(makeRow({ id: "d1", deleteAfter: "2026-03-01T00:00:00.000Z" }));
|
|
42
|
+
await insertRepo.insert(makeRow({ id: "d2", deleteAfter: "2026-03-01T00:00:00.000Z" }));
|
|
43
|
+
await insertRepo.cancel("d2", "cancelled");
|
|
44
|
+
await repo.markCompleted("d1", '{"users":1}');
|
|
45
|
+
const ripe = await repo.findRipe("2026-03-10T00:00:00.000Z");
|
|
46
|
+
expect(ripe).toHaveLength(0);
|
|
47
|
+
});
|
|
48
|
+
it("returns empty array when no ripe requests exist", async () => {
|
|
49
|
+
await insertRepo.insert(makeRow({ id: "f1", deleteAfter: "2026-12-01T00:00:00.000Z" }));
|
|
50
|
+
expect(await repo.findRipe("2026-03-10T00:00:00.000Z")).toEqual([]);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
describe("markCompleted", () => {
|
|
54
|
+
it("marks a pending request as completed with summary", async () => {
|
|
55
|
+
await insertRepo.insert(makeRow({ id: "mc-1" }));
|
|
56
|
+
const result = await repo.markCompleted("mc-1", '{"users":5,"sessions":12}');
|
|
57
|
+
expect(result).toBe(true);
|
|
58
|
+
const row = await insertRepo.getById("mc-1");
|
|
59
|
+
expect(row?.status).toBe("completed");
|
|
60
|
+
expect(row?.deletionSummary).toBe('{"users":5,"sessions":12}');
|
|
61
|
+
expect(row?.completedAt).toMatch(/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}/);
|
|
62
|
+
});
|
|
63
|
+
it("returns false for non-existent ID", async () => {
|
|
64
|
+
expect(await repo.markCompleted("missing", "{}")).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
it("returns false for already-completed request", async () => {
|
|
67
|
+
await insertRepo.insert(makeRow({ id: "mc-2" }));
|
|
68
|
+
await repo.markCompleted("mc-2", "{}");
|
|
69
|
+
expect(await repo.markCompleted("mc-2", "{}")).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
describe("findPendingByTenant", () => {
|
|
73
|
+
it("returns the pending request for a tenant", async () => {
|
|
74
|
+
await insertRepo.insert(makeRow({ id: "p1", tenantId: "t1" }));
|
|
75
|
+
const row = await repo.findPendingByTenant("t1");
|
|
76
|
+
expect(row).not.toBeNull();
|
|
77
|
+
expect(row?.id).toBe("p1");
|
|
78
|
+
expect(row?.status).toBe("pending");
|
|
79
|
+
});
|
|
80
|
+
it("returns null when no pending request exists", async () => {
|
|
81
|
+
expect(await repo.findPendingByTenant("unknown")).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
it("returns null when tenant only has completed/cancelled requests", async () => {
|
|
84
|
+
await insertRepo.insert(makeRow({ id: "p2", tenantId: "t2" }));
|
|
85
|
+
await insertRepo.cancel("p2", "cancelled");
|
|
86
|
+
expect(await repo.findPendingByTenant("t2")).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { PlatformDb } from "../db/index.js";
|
|
2
|
+
import { accountDeletionRequests } from "../db/schema/index.js";
|
|
3
|
+
import type { DeletionRequestRow, InsertDeletionRequest } from "./repository-types.js";
|
|
4
|
+
export type { DeletionRequestRow, InsertDeletionRequest };
|
|
5
|
+
export interface IDeletionRepository {
|
|
6
|
+
insert(data: InsertDeletionRequest): Promise<void>;
|
|
7
|
+
getById(id: string): Promise<DeletionRequestRow | null>;
|
|
8
|
+
listByTenant(tenantId: string): Promise<DeletionRequestRow[]>;
|
|
9
|
+
cancel(id: string, cancelReason: string): Promise<boolean>;
|
|
10
|
+
}
|
|
11
|
+
export declare class DrizzleDeletionRepository implements IDeletionRepository {
|
|
12
|
+
private readonly db;
|
|
13
|
+
constructor(db: PlatformDb);
|
|
14
|
+
insert(data: InsertDeletionRequest): Promise<void>;
|
|
15
|
+
getById(id: string): Promise<DeletionRequestRow | null>;
|
|
16
|
+
listByTenant(tenantId: string): Promise<DeletionRequestRow[]>;
|
|
17
|
+
cancel(id: string, cancelReason: string): Promise<boolean>;
|
|
18
|
+
}
|
|
19
|
+
export declare function toRow(row: typeof accountDeletionRequests.$inferSelect): DeletionRequestRow;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { and, eq, sql } from "drizzle-orm";
|
|
2
|
+
import { accountDeletionRequests } from "../db/schema/index.js";
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Implementation
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
export class DrizzleDeletionRepository {
|
|
7
|
+
db;
|
|
8
|
+
constructor(db) {
|
|
9
|
+
this.db = db;
|
|
10
|
+
}
|
|
11
|
+
async insert(data) {
|
|
12
|
+
await this.db.insert(accountDeletionRequests).values({
|
|
13
|
+
id: data.id,
|
|
14
|
+
tenantId: data.tenantId,
|
|
15
|
+
requestedBy: data.requestedBy,
|
|
16
|
+
deleteAfter: data.deleteAfter,
|
|
17
|
+
reason: data.reason ?? null,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
async getById(id) {
|
|
21
|
+
const rows = await this.db.select().from(accountDeletionRequests).where(eq(accountDeletionRequests.id, id));
|
|
22
|
+
const row = rows[0];
|
|
23
|
+
return row ? toRow(row) : null;
|
|
24
|
+
}
|
|
25
|
+
async listByTenant(tenantId) {
|
|
26
|
+
const rows = await this.db
|
|
27
|
+
.select()
|
|
28
|
+
.from(accountDeletionRequests)
|
|
29
|
+
.where(eq(accountDeletionRequests.tenantId, tenantId));
|
|
30
|
+
return rows.map(toRow);
|
|
31
|
+
}
|
|
32
|
+
async cancel(id, cancelReason) {
|
|
33
|
+
const result = await this.db
|
|
34
|
+
.update(accountDeletionRequests)
|
|
35
|
+
.set({
|
|
36
|
+
status: "cancelled",
|
|
37
|
+
cancelReason,
|
|
38
|
+
updatedAt: sql `now()`,
|
|
39
|
+
})
|
|
40
|
+
.where(and(eq(accountDeletionRequests.id, id), eq(accountDeletionRequests.status, "pending")))
|
|
41
|
+
.returning({ id: accountDeletionRequests.id });
|
|
42
|
+
return result.length > 0;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Row mapper
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
export function toRow(row) {
|
|
49
|
+
return {
|
|
50
|
+
id: row.id,
|
|
51
|
+
tenantId: row.tenantId,
|
|
52
|
+
requestedBy: row.requestedBy,
|
|
53
|
+
status: row.status,
|
|
54
|
+
deleteAfter: row.deleteAfter,
|
|
55
|
+
reason: row.reason,
|
|
56
|
+
cancelReason: row.cancelReason,
|
|
57
|
+
completedAt: row.completedAt,
|
|
58
|
+
deletionSummary: row.deletionSummary,
|
|
59
|
+
createdAt: row.createdAt,
|
|
60
|
+
updatedAt: row.updatedAt,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createTestDb, truncateAllTables } from "../test/db.js";
|
|
3
|
+
import { DrizzleDeletionExecutorRepository } from "./deletion-executor-repository.js";
|
|
4
|
+
import { DrizzleDeletionRepository } from "./deletion-repository.js";
|
|
5
|
+
function makeRow(overrides = {}) {
|
|
6
|
+
return {
|
|
7
|
+
id: overrides.id ?? "del-001",
|
|
8
|
+
tenantId: overrides.tenantId ?? "tenant-1",
|
|
9
|
+
requestedBy: overrides.requestedBy ?? "user-1",
|
|
10
|
+
deleteAfter: overrides.deleteAfter ?? "2026-04-10T00:00:00.000Z",
|
|
11
|
+
reason: overrides.reason,
|
|
12
|
+
...overrides,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
describe("DrizzleDeletionRepository", () => {
|
|
16
|
+
let pool;
|
|
17
|
+
let db;
|
|
18
|
+
let repo;
|
|
19
|
+
let executorRepo;
|
|
20
|
+
beforeAll(async () => {
|
|
21
|
+
({ db, pool } = await createTestDb());
|
|
22
|
+
});
|
|
23
|
+
afterAll(async () => {
|
|
24
|
+
await pool.close();
|
|
25
|
+
});
|
|
26
|
+
beforeEach(async () => {
|
|
27
|
+
await truncateAllTables(pool);
|
|
28
|
+
repo = new DrizzleDeletionRepository(db);
|
|
29
|
+
executorRepo = new DrizzleDeletionExecutorRepository(db);
|
|
30
|
+
});
|
|
31
|
+
describe("insert + getById", () => {
|
|
32
|
+
it("inserts a deletion request and reads it back", async () => {
|
|
33
|
+
await repo.insert(makeRow({ reason: "Account no longer needed" }));
|
|
34
|
+
const row = await repo.getById("del-001");
|
|
35
|
+
expect(row).not.toBeNull();
|
|
36
|
+
expect(row?.tenantId).toBe("tenant-1");
|
|
37
|
+
expect(row?.requestedBy).toBe("user-1");
|
|
38
|
+
expect(row?.status).toBe("pending");
|
|
39
|
+
expect(row?.deleteAfter).toBe("2026-04-10T00:00:00.000Z");
|
|
40
|
+
expect(row?.reason).toBe("Account no longer needed");
|
|
41
|
+
expect(row?.cancelReason).toBeNull();
|
|
42
|
+
expect(row?.completedAt).toBeNull();
|
|
43
|
+
expect(row?.deletionSummary).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
it("returns null for non-existent ID", async () => {
|
|
46
|
+
expect(await repo.getById("no-such")).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
describe("listByTenant", () => {
|
|
50
|
+
it("returns all requests for a tenant", async () => {
|
|
51
|
+
await repo.insert(makeRow({ id: "d1", tenantId: "t1" }));
|
|
52
|
+
await repo.insert(makeRow({ id: "d2", tenantId: "t1" }));
|
|
53
|
+
await repo.insert(makeRow({ id: "d3", tenantId: "t2" }));
|
|
54
|
+
const rows = await repo.listByTenant("t1");
|
|
55
|
+
expect(rows).toHaveLength(2);
|
|
56
|
+
expect(rows.map((r) => r.id).sort()).toEqual(["d1", "d2"]);
|
|
57
|
+
});
|
|
58
|
+
it("returns empty array for unknown tenant", async () => {
|
|
59
|
+
expect(await repo.listByTenant("unknown")).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
describe("cancel", () => {
|
|
63
|
+
it("cancels a pending request", async () => {
|
|
64
|
+
await repo.insert(makeRow({ id: "c1" }));
|
|
65
|
+
const result = await repo.cancel("c1", "Changed my mind");
|
|
66
|
+
expect(result).toBe(true);
|
|
67
|
+
const row = await repo.getById("c1");
|
|
68
|
+
expect(row?.status).toBe("cancelled");
|
|
69
|
+
expect(row?.cancelReason).toBe("Changed my mind");
|
|
70
|
+
});
|
|
71
|
+
it("returns false for non-existent ID", async () => {
|
|
72
|
+
expect(await repo.cancel("missing", "reason")).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
it("returns false when request is already completed", async () => {
|
|
75
|
+
await repo.insert(makeRow({ id: "c2" }));
|
|
76
|
+
await executorRepo.markCompleted("c2", "{}");
|
|
77
|
+
expect(await repo.cancel("c2", "Too late")).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
it("returns false when request is already cancelled", async () => {
|
|
80
|
+
await repo.insert(makeRow({ id: "c3" }));
|
|
81
|
+
await repo.cancel("c3", "First cancel");
|
|
82
|
+
expect(await repo.cancel("c3", "Second cancel")).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { PlatformDb } from "../db/index.js";
|
|
2
|
+
import type { ExportRequestRow, InsertExportRequest } from "./repository-types.js";
|
|
3
|
+
export type { ExportRequestRow, InsertExportRequest };
|
|
4
|
+
export interface IExportRepository {
|
|
5
|
+
insert(data: InsertExportRequest): Promise<void>;
|
|
6
|
+
getById(id: string): Promise<ExportRequestRow | null>;
|
|
7
|
+
listByTenant(tenantId: string): Promise<ExportRequestRow[]>;
|
|
8
|
+
markProcessing(id: string): Promise<boolean>;
|
|
9
|
+
markCompleted(id: string, downloadUrl: string): Promise<boolean>;
|
|
10
|
+
markFailed(id: string, errorMessage?: string): Promise<boolean>;
|
|
11
|
+
}
|
|
12
|
+
export declare class DrizzleExportRepository implements IExportRepository {
|
|
13
|
+
private readonly db;
|
|
14
|
+
constructor(db: PlatformDb);
|
|
15
|
+
insert(data: InsertExportRequest): Promise<void>;
|
|
16
|
+
getById(id: string): Promise<ExportRequestRow | null>;
|
|
17
|
+
listByTenant(tenantId: string): Promise<ExportRequestRow[]>;
|
|
18
|
+
markProcessing(id: string): Promise<boolean>;
|
|
19
|
+
markCompleted(id: string, downloadUrl: string): Promise<boolean>;
|
|
20
|
+
markFailed(id: string, _errorMessage?: string): Promise<boolean>;
|
|
21
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { and, eq, sql } from "drizzle-orm";
|
|
2
|
+
import { accountExportRequests } from "../db/schema/index.js";
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Implementation
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
export class DrizzleExportRepository {
|
|
7
|
+
db;
|
|
8
|
+
constructor(db) {
|
|
9
|
+
this.db = db;
|
|
10
|
+
}
|
|
11
|
+
async insert(data) {
|
|
12
|
+
await this.db.insert(accountExportRequests).values({
|
|
13
|
+
id: data.id,
|
|
14
|
+
tenantId: data.tenantId,
|
|
15
|
+
requestedBy: data.requestedBy,
|
|
16
|
+
format: data.format ?? "json",
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
async getById(id) {
|
|
20
|
+
const rows = await this.db.select().from(accountExportRequests).where(eq(accountExportRequests.id, id));
|
|
21
|
+
const row = rows[0];
|
|
22
|
+
return row ? toRow(row) : null;
|
|
23
|
+
}
|
|
24
|
+
async listByTenant(tenantId) {
|
|
25
|
+
const rows = await this.db.select().from(accountExportRequests).where(eq(accountExportRequests.tenantId, tenantId));
|
|
26
|
+
return rows.map(toRow);
|
|
27
|
+
}
|
|
28
|
+
async markProcessing(id) {
|
|
29
|
+
const result = await this.db
|
|
30
|
+
.update(accountExportRequests)
|
|
31
|
+
.set({
|
|
32
|
+
status: "processing",
|
|
33
|
+
updatedAt: sql `now()`,
|
|
34
|
+
})
|
|
35
|
+
.where(and(eq(accountExportRequests.id, id), eq(accountExportRequests.status, "pending")))
|
|
36
|
+
.returning({ id: accountExportRequests.id });
|
|
37
|
+
return result.length > 0;
|
|
38
|
+
}
|
|
39
|
+
async markCompleted(id, downloadUrl) {
|
|
40
|
+
const result = await this.db
|
|
41
|
+
.update(accountExportRequests)
|
|
42
|
+
.set({
|
|
43
|
+
status: "completed",
|
|
44
|
+
downloadUrl,
|
|
45
|
+
updatedAt: sql `now()`,
|
|
46
|
+
})
|
|
47
|
+
.where(and(eq(accountExportRequests.id, id), eq(accountExportRequests.status, "processing")))
|
|
48
|
+
.returning({ id: accountExportRequests.id });
|
|
49
|
+
return result.length > 0;
|
|
50
|
+
}
|
|
51
|
+
async markFailed(id, _errorMessage) {
|
|
52
|
+
const result = await this.db
|
|
53
|
+
.update(accountExportRequests)
|
|
54
|
+
.set({
|
|
55
|
+
status: "failed",
|
|
56
|
+
updatedAt: sql `now()`,
|
|
57
|
+
})
|
|
58
|
+
.where(and(eq(accountExportRequests.id, id), eq(accountExportRequests.status, "processing")))
|
|
59
|
+
.returning({ id: accountExportRequests.id });
|
|
60
|
+
return result.length > 0;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Row mapper
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
function toRow(row) {
|
|
67
|
+
return {
|
|
68
|
+
id: row.id,
|
|
69
|
+
tenantId: row.tenantId,
|
|
70
|
+
requestedBy: row.requestedBy,
|
|
71
|
+
status: row.status,
|
|
72
|
+
format: row.format,
|
|
73
|
+
downloadUrl: row.downloadUrl,
|
|
74
|
+
createdAt: row.createdAt,
|
|
75
|
+
updatedAt: row.updatedAt,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createTestDb, truncateAllTables } from "../test/db.js";
|
|
3
|
+
import { DrizzleExportRepository } from "./export-repository.js";
|
|
4
|
+
function makeRow(overrides = {}) {
|
|
5
|
+
return {
|
|
6
|
+
id: overrides.id ?? "exp-001",
|
|
7
|
+
tenantId: overrides.tenantId ?? "tenant-1",
|
|
8
|
+
requestedBy: overrides.requestedBy ?? "user-1",
|
|
9
|
+
format: overrides.format,
|
|
10
|
+
...overrides,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
describe("DrizzleExportRepository", () => {
|
|
14
|
+
let pool;
|
|
15
|
+
let db;
|
|
16
|
+
let repo;
|
|
17
|
+
beforeAll(async () => {
|
|
18
|
+
({ db, pool } = await createTestDb());
|
|
19
|
+
});
|
|
20
|
+
afterAll(async () => {
|
|
21
|
+
await pool.close();
|
|
22
|
+
});
|
|
23
|
+
beforeEach(async () => {
|
|
24
|
+
await truncateAllTables(pool);
|
|
25
|
+
repo = new DrizzleExportRepository(db);
|
|
26
|
+
});
|
|
27
|
+
describe("insert + getById", () => {
|
|
28
|
+
it("inserts an export request and reads it back", async () => {
|
|
29
|
+
await repo.insert(makeRow());
|
|
30
|
+
const row = await repo.getById("exp-001");
|
|
31
|
+
expect(row).not.toBeNull();
|
|
32
|
+
expect(row?.tenantId).toBe("tenant-1");
|
|
33
|
+
expect(row?.requestedBy).toBe("user-1");
|
|
34
|
+
expect(row?.status).toBe("pending");
|
|
35
|
+
expect(row?.format).toBe("json");
|
|
36
|
+
expect(row?.downloadUrl).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
it("respects custom format", async () => {
|
|
39
|
+
await repo.insert(makeRow({ id: "exp-csv", format: "csv" }));
|
|
40
|
+
const row = await repo.getById("exp-csv");
|
|
41
|
+
expect(row?.format).toBe("csv");
|
|
42
|
+
});
|
|
43
|
+
it("returns null for non-existent ID", async () => {
|
|
44
|
+
expect(await repo.getById("no-such")).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
describe("listByTenant", () => {
|
|
48
|
+
it("returns all export requests for a tenant", async () => {
|
|
49
|
+
await repo.insert(makeRow({ id: "e1", tenantId: "t1" }));
|
|
50
|
+
await repo.insert(makeRow({ id: "e2", tenantId: "t1" }));
|
|
51
|
+
await repo.insert(makeRow({ id: "e3", tenantId: "t2" }));
|
|
52
|
+
const rows = await repo.listByTenant("t1");
|
|
53
|
+
expect(rows).toHaveLength(2);
|
|
54
|
+
expect(rows.map((r) => r.id).sort()).toEqual(["e1", "e2"]);
|
|
55
|
+
});
|
|
56
|
+
it("returns empty array for unknown tenant", async () => {
|
|
57
|
+
expect(await repo.listByTenant("unknown")).toEqual([]);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
describe("markProcessing", () => {
|
|
61
|
+
it("transitions pending to processing", async () => {
|
|
62
|
+
await repo.insert(makeRow({ id: "mp-1" }));
|
|
63
|
+
expect(await repo.markProcessing("mp-1")).toBe(true);
|
|
64
|
+
const row = await repo.getById("mp-1");
|
|
65
|
+
expect(row?.status).toBe("processing");
|
|
66
|
+
});
|
|
67
|
+
it("returns false for non-existent ID", async () => {
|
|
68
|
+
expect(await repo.markProcessing("missing")).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
it("returns false when not in pending state", async () => {
|
|
71
|
+
await repo.insert(makeRow({ id: "mp-2" }));
|
|
72
|
+
await repo.markProcessing("mp-2");
|
|
73
|
+
expect(await repo.markProcessing("mp-2")).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
describe("markCompleted", () => {
|
|
77
|
+
it("transitions processing to completed with download URL", async () => {
|
|
78
|
+
await repo.insert(makeRow({ id: "mc-1" }));
|
|
79
|
+
await repo.markProcessing("mc-1");
|
|
80
|
+
expect(await repo.markCompleted("mc-1", "https://storage.example.com/export-mc-1.zip")).toBe(true);
|
|
81
|
+
const row = await repo.getById("mc-1");
|
|
82
|
+
expect(row?.status).toBe("completed");
|
|
83
|
+
expect(row?.downloadUrl).toBe("https://storage.example.com/export-mc-1.zip");
|
|
84
|
+
});
|
|
85
|
+
it("returns false when not in processing state", async () => {
|
|
86
|
+
await repo.insert(makeRow({ id: "mc-2" }));
|
|
87
|
+
expect(await repo.markCompleted("mc-2", "https://example.com")).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
it("returns false for non-existent ID", async () => {
|
|
90
|
+
expect(await repo.markCompleted("missing", "https://example.com")).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
describe("markFailed", () => {
|
|
94
|
+
it("transitions processing to failed", async () => {
|
|
95
|
+
await repo.insert(makeRow({ id: "mf-1" }));
|
|
96
|
+
await repo.markProcessing("mf-1");
|
|
97
|
+
expect(await repo.markFailed("mf-1")).toBe(true);
|
|
98
|
+
const row = await repo.getById("mf-1");
|
|
99
|
+
expect(row?.status).toBe("failed");
|
|
100
|
+
});
|
|
101
|
+
it("returns false when not in processing state", async () => {
|
|
102
|
+
await repo.insert(makeRow({ id: "mf-2" }));
|
|
103
|
+
expect(await repo.markFailed("mf-2")).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
it("returns false for non-existent ID", async () => {
|
|
106
|
+
expect(await repo.markFailed("missing")).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { DrizzleDeletionExecutorRepository, type IDeletionExecutorRepository } from "./deletion-executor-repository.js";
|
|
2
|
+
export { DrizzleDeletionRepository, type IDeletionRepository, } from "./deletion-repository.js";
|
|
3
|
+
export { DrizzleExportRepository, type IExportRepository } from "./export-repository.js";
|
|
4
|
+
export type { DeletionRequestRow, DeletionStatus, ExportRequestRow, ExportStatus, InsertDeletionRequest, InsertExportRequest, } from "./repository-types.js";
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export type DeletionStatus = "pending" | "completed" | "cancelled";
|
|
2
|
+
export type ExportStatus = "pending" | "processing" | "completed" | "failed";
|
|
3
|
+
export interface DeletionRequestRow {
|
|
4
|
+
id: string;
|
|
5
|
+
tenantId: string;
|
|
6
|
+
requestedBy: string;
|
|
7
|
+
status: DeletionStatus;
|
|
8
|
+
deleteAfter: string;
|
|
9
|
+
reason: string | null;
|
|
10
|
+
cancelReason: string | null;
|
|
11
|
+
completedAt: string | null;
|
|
12
|
+
deletionSummary: string | null;
|
|
13
|
+
createdAt: string;
|
|
14
|
+
updatedAt: string;
|
|
15
|
+
}
|
|
16
|
+
export interface InsertDeletionRequest {
|
|
17
|
+
id: string;
|
|
18
|
+
tenantId: string;
|
|
19
|
+
requestedBy: string;
|
|
20
|
+
deleteAfter: string;
|
|
21
|
+
reason?: string;
|
|
22
|
+
}
|
|
23
|
+
export interface ExportRequestRow {
|
|
24
|
+
id: string;
|
|
25
|
+
tenantId: string;
|
|
26
|
+
requestedBy: string;
|
|
27
|
+
status: ExportStatus;
|
|
28
|
+
format: string;
|
|
29
|
+
downloadUrl: string | null;
|
|
30
|
+
createdAt: Date;
|
|
31
|
+
updatedAt: Date;
|
|
32
|
+
}
|
|
33
|
+
export interface InsertExportRequest {
|
|
34
|
+
id: string;
|
|
35
|
+
tenantId: string;
|
|
36
|
+
requestedBy: string;
|
|
37
|
+
format?: string;
|
|
38
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|