@gigamusic/admin 2.1.0 → 3.0.0
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/package.json +5 -5
- package/src/handlers/links.ts +61 -42
- package/src/handlers/orders.ts +7 -4
- package/src/handlers/releases.ts +159 -27
- package/src/handlers/settings.ts +39 -16
- package/src/handlers/upload.ts +8 -2
- package/src/lib/types.ts +36 -3
- package/src/server.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gigamusic/admin",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "Server-only admin API handler factories for the gigamusic platform (releases CRUD, tracks, uploads, settings, links, orders).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -24,10 +24,10 @@
|
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"zod": "^3.24.1",
|
|
27
|
-
"@gigamusic/
|
|
28
|
-
"@gigamusic/
|
|
29
|
-
"@gigamusic/storage": "
|
|
30
|
-
"@gigamusic/
|
|
27
|
+
"@gigamusic/db": "3.0.0",
|
|
28
|
+
"@gigamusic/audio": "3.0.0",
|
|
29
|
+
"@gigamusic/storage": "3.0.0",
|
|
30
|
+
"@gigamusic/core": "3.0.0"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"@types/node": "^22.10.5",
|
package/src/handlers/links.ts
CHANGED
|
@@ -1,32 +1,46 @@
|
|
|
1
|
-
import
|
|
2
|
-
import type {
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type {
|
|
3
|
+
DefaultTables,
|
|
4
|
+
LinkInput,
|
|
5
|
+
Queries,
|
|
6
|
+
QueryTables,
|
|
7
|
+
} from "@gigamusic/db";
|
|
8
|
+
import type { AdminLogger, RouteHandler } from "../lib/types";
|
|
3
9
|
import { badRequest, json, notFound } from "../lib/responses";
|
|
4
10
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Base validation schema for a global `/links` page entry. Consumers extend
|
|
13
|
+
* this to validate extras (e.g. an internal flag) and pass it as `inputSchema`.
|
|
14
|
+
*/
|
|
15
|
+
export const LinkInputSchema = z.object({
|
|
16
|
+
id: z.number().int().optional(),
|
|
17
|
+
title: z.string().trim().min(1),
|
|
18
|
+
url: z.string().trim().min(1),
|
|
19
|
+
position: z.number().int().nonnegative().optional(),
|
|
20
|
+
isVisible: z.boolean().optional(),
|
|
21
|
+
showOnHero: z.boolean().optional(),
|
|
22
|
+
});
|
|
13
23
|
|
|
14
24
|
interface ReorderBody {
|
|
15
25
|
orderedIds?: unknown;
|
|
16
26
|
}
|
|
17
27
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
export interface LinksHandlerDeps<
|
|
29
|
+
T extends QueryTables = DefaultTables,
|
|
30
|
+
|
|
31
|
+
TInput extends z.ZodObject<any> = typeof LinkInputSchema,
|
|
32
|
+
> {
|
|
33
|
+
queries: Queries<T>;
|
|
34
|
+
logger?: AdminLogger;
|
|
35
|
+
/**
|
|
36
|
+
* Defaults to `LinkInputSchema`. Pass an `.extend()`ed schema to validate
|
|
37
|
+
* consumer extras on the wire. Note that `queries.upsertLink` only writes
|
|
38
|
+
* gigamusic-managed columns — consumers persist their extras via an
|
|
39
|
+
* `afterWrite` hook (best-effort, not tx-atomic with the gigamusic write).
|
|
40
|
+
*/
|
|
41
|
+
inputSchema?: TInput;
|
|
42
|
+
/** Invoked after `queries.upsertLink` resolves. Not wrapped in a transaction. */
|
|
43
|
+
afterWrite?: (linkId: number, input: z.infer<TInput>) => Promise<void>;
|
|
30
44
|
}
|
|
31
45
|
|
|
32
46
|
/**
|
|
@@ -41,27 +55,39 @@ function toLinkInput(body: LinkBody): LinkInput | null {
|
|
|
41
55
|
* Auth: the package assumes the request has been gated by the consumer's
|
|
42
56
|
* `proxy.ts` / middleware. These handlers don't verify a session.
|
|
43
57
|
*/
|
|
44
|
-
export function createAdminLinksHandlers
|
|
45
|
-
|
|
46
|
-
|
|
58
|
+
export function createAdminLinksHandlers<
|
|
59
|
+
T extends QueryTables = DefaultTables,
|
|
60
|
+
|
|
61
|
+
TInput extends z.ZodObject<any> = typeof LinkInputSchema,
|
|
62
|
+
>(deps: LinksHandlerDeps<T, TInput>): {
|
|
47
63
|
GET: RouteHandler;
|
|
48
64
|
POST: RouteHandler;
|
|
49
65
|
PUT: RouteHandler;
|
|
50
66
|
DELETE: RouteHandler;
|
|
51
67
|
} {
|
|
68
|
+
const inputSchema = (deps.inputSchema ?? LinkInputSchema) as TInput;
|
|
69
|
+
|
|
70
|
+
const runUpsert = async (input: z.infer<TInput>): Promise<unknown> => {
|
|
71
|
+
const link = await deps.queries.upsertLink(input as unknown as LinkInput);
|
|
72
|
+
if (deps.afterWrite) {
|
|
73
|
+
await deps.afterWrite((link as { id: number }).id, input);
|
|
74
|
+
}
|
|
75
|
+
return link;
|
|
76
|
+
};
|
|
77
|
+
|
|
52
78
|
return {
|
|
53
79
|
GET: async () => {
|
|
54
80
|
return json(await deps.queries.listAllLinks());
|
|
55
81
|
},
|
|
56
82
|
POST: async (req) => {
|
|
57
|
-
const body = (await safeJson(req)) as
|
|
58
|
-
const
|
|
59
|
-
if (!
|
|
60
|
-
const link = await
|
|
83
|
+
const body = (await safeJson(req)) as unknown;
|
|
84
|
+
const parsed = inputSchema.safeParse(body);
|
|
85
|
+
if (!parsed.success) return badRequest("title and url are required");
|
|
86
|
+
const link = await runUpsert(parsed.data as z.infer<TInput>);
|
|
61
87
|
return json(link, { status: 201 });
|
|
62
88
|
},
|
|
63
89
|
PUT: async (req) => {
|
|
64
|
-
const body = (await safeJson(req)) as
|
|
90
|
+
const body = (await safeJson(req)) as ReorderBody & Record<string, unknown>;
|
|
65
91
|
if (Array.isArray(body.orderedIds)) {
|
|
66
92
|
const ids = body.orderedIds
|
|
67
93
|
.map((x) => (typeof x === "number" ? x : Number(x)))
|
|
@@ -69,18 +95,11 @@ export function createAdminLinksHandlers(
|
|
|
69
95
|
await deps.queries.reorderLinks(ids);
|
|
70
96
|
return json({ ok: true });
|
|
71
97
|
}
|
|
72
|
-
const
|
|
73
|
-
if (
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const link = await
|
|
77
|
-
id,
|
|
78
|
-
title: typeof body.title === "string" ? body.title : "",
|
|
79
|
-
url: typeof body.url === "string" ? body.url : "",
|
|
80
|
-
position: typeof body.position === "number" ? body.position : undefined,
|
|
81
|
-
isVisible: typeof body.isVisible === "boolean" ? body.isVisible : undefined,
|
|
82
|
-
showOnHero: typeof body.showOnHero === "boolean" ? body.showOnHero : undefined,
|
|
83
|
-
});
|
|
98
|
+
const parsed = inputSchema.safeParse(body);
|
|
99
|
+
if (!parsed.success) return badRequest("Validation failed");
|
|
100
|
+
const dataWithId = parsed.data as { id?: number };
|
|
101
|
+
if (dataWithId.id == null) return badRequest("id is required");
|
|
102
|
+
const link = await runUpsert(parsed.data as z.infer<TInput>);
|
|
84
103
|
return json(link);
|
|
85
104
|
},
|
|
86
105
|
DELETE: async (req) => {
|
package/src/handlers/orders.ts
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { DefaultTables, Queries, QueryTables } from "@gigamusic/db";
|
|
2
|
+
import type { RouteHandler } from "../lib/types";
|
|
2
3
|
import { badRequest, json } from "../lib/responses";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* GET /api/admin/orders?email=… — returns the order history for a given
|
|
6
7
|
* email address.
|
|
7
8
|
*
|
|
9
|
+
* Read-only; no `inputSchema` / hooks since there's no write.
|
|
10
|
+
*
|
|
8
11
|
* Auth: the package assumes the request has been gated by the consumer's
|
|
9
12
|
* `proxy.ts` / middleware. This handler doesn't verify a session.
|
|
10
13
|
*/
|
|
11
|
-
export function createAdminOrdersHandler(
|
|
12
|
-
|
|
13
|
-
): RouteHandler {
|
|
14
|
+
export function createAdminOrdersHandler<T extends QueryTables = DefaultTables>(deps: {
|
|
15
|
+
queries: Queries<T>;
|
|
16
|
+
}): RouteHandler {
|
|
14
17
|
return async (req) => {
|
|
15
18
|
const url = new URL(req.url);
|
|
16
19
|
const email = url.searchParams.get("email");
|
package/src/handlers/releases.ts
CHANGED
|
@@ -4,16 +4,31 @@ import {
|
|
|
4
4
|
TrackInputSchema,
|
|
5
5
|
type ReleaseInput,
|
|
6
6
|
} from "@gigamusic/core";
|
|
7
|
-
import {
|
|
8
|
-
|
|
7
|
+
import {
|
|
8
|
+
insertReleaseTx,
|
|
9
|
+
isUniqueConstraintError as isUniqueConstraintErrorByCode,
|
|
10
|
+
updateReleaseTx,
|
|
11
|
+
type DefaultTables,
|
|
12
|
+
type GigamusicDb,
|
|
13
|
+
type Queries,
|
|
14
|
+
type QueryTables,
|
|
15
|
+
type Tx,
|
|
16
|
+
} from "@gigamusic/db";
|
|
17
|
+
import type {
|
|
18
|
+
AdminDeps,
|
|
19
|
+
AdminLogger,
|
|
20
|
+
RouteHandler,
|
|
21
|
+
WriteHookContext,
|
|
22
|
+
} from "../lib/types";
|
|
23
|
+
import type { StorageProvider } from "@gigamusic/storage";
|
|
9
24
|
import { badRequest, json, notFound } from "../lib/responses";
|
|
10
25
|
|
|
11
|
-
type ReleasesDeps = Pick<AdminDeps, "queries" | "storage" | "logger">;
|
|
12
|
-
|
|
13
26
|
/**
|
|
14
27
|
* Draft releases skip the strict published-validation rules so an admin can
|
|
15
|
-
* save in-progress work.
|
|
16
|
-
*
|
|
28
|
+
* save in-progress work. Built off the *base* `ReleaseInputSchema` — consumer
|
|
29
|
+
* extension columns are NOT carried into the draft variant, because draft
|
|
30
|
+
* validation stays gigamusic-managed. Consumers who want their extras
|
|
31
|
+
* validated on draft must pass an extended `inputSchema` themselves.
|
|
17
32
|
*/
|
|
18
33
|
const DraftReleaseSchema = ReleaseInputSchema.extend({
|
|
19
34
|
isPublished: z.literal(false),
|
|
@@ -23,8 +38,6 @@ const DraftReleaseSchema = ReleaseInputSchema.extend({
|
|
|
23
38
|
tracks: z.array(TrackInputSchema.partial({ files: true, price: true })).optional(),
|
|
24
39
|
});
|
|
25
40
|
|
|
26
|
-
const ReleasePayloadSchema = z.union([ReleaseInputSchema, DraftReleaseSchema]);
|
|
27
|
-
|
|
28
41
|
/**
|
|
29
42
|
* Flatten zod issues into `{ "tracks[0].artist": ["msg", …] }` shape — matches
|
|
30
43
|
* the field-error format the admin form expects.
|
|
@@ -42,32 +55,74 @@ function flattenIssues(error: z.ZodError): Record<string, string[]> {
|
|
|
42
55
|
return out;
|
|
43
56
|
}
|
|
44
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Deps shape shared by both releases handler factories.
|
|
60
|
+
*
|
|
61
|
+
* - `inputSchema`: defaults to `ReleaseInputSchema`. Pass an `.extend()`ed
|
|
62
|
+
* schema to validate consumer extras alongside the gigamusic-managed
|
|
63
|
+
* columns on the *published* path. Draft validation always uses gigamusic's
|
|
64
|
+
* own draft schema; extras are passed straight through.
|
|
65
|
+
* - `beforeWrite` / `afterWrite`: invoked inside the same Drizzle transaction
|
|
66
|
+
* as the gigamusic INSERT/UPDATE. Returning rejected → tx rolls back.
|
|
67
|
+
* `db` + `tables` must be supplied on `AdminDeps` for hooks to be honored.
|
|
68
|
+
*/
|
|
69
|
+
export interface ReleasesHandlerDeps<
|
|
70
|
+
T extends QueryTables = DefaultTables,
|
|
71
|
+
|
|
72
|
+
TInput extends z.ZodObject<any> = typeof ReleaseInputSchema,
|
|
73
|
+
> {
|
|
74
|
+
queries: Queries<T>;
|
|
75
|
+
db?: GigamusicDb<T>;
|
|
76
|
+
tables?: T;
|
|
77
|
+
storage?: StorageProvider;
|
|
78
|
+
logger?: AdminLogger;
|
|
79
|
+
inputSchema?: TInput;
|
|
80
|
+
beforeWrite?: (
|
|
81
|
+
input: z.infer<TInput>,
|
|
82
|
+
ctx: WriteHookContext,
|
|
83
|
+
) => Promise<void>;
|
|
84
|
+
afterWrite?: (
|
|
85
|
+
releaseId: number,
|
|
86
|
+
input: z.infer<TInput>,
|
|
87
|
+
ctx: WriteHookContext,
|
|
88
|
+
) => Promise<void>;
|
|
89
|
+
}
|
|
90
|
+
|
|
45
91
|
/**
|
|
46
92
|
* GET /api/admin/releases and POST /api/admin/releases.
|
|
47
93
|
*
|
|
48
94
|
* Auth: the package assumes the request has been gated by the consumer's
|
|
49
95
|
* `proxy.ts` / middleware. These handlers don't verify a session.
|
|
50
96
|
*/
|
|
51
|
-
export function createAdminReleasesHandlers
|
|
52
|
-
|
|
53
|
-
|
|
97
|
+
export function createAdminReleasesHandlers<
|
|
98
|
+
T extends QueryTables = DefaultTables,
|
|
99
|
+
|
|
100
|
+
TInput extends z.ZodObject<any> = typeof ReleaseInputSchema,
|
|
101
|
+
>(deps: ReleasesHandlerDeps<T, TInput>): { GET: RouteHandler; POST: RouteHandler } {
|
|
102
|
+
const inputSchema = (deps.inputSchema ?? ReleaseInputSchema) as TInput;
|
|
103
|
+
const payloadSchema = z.union([inputSchema, DraftReleaseSchema]);
|
|
104
|
+
const hooksConfigured = Boolean(deps.beforeWrite || deps.afterWrite);
|
|
105
|
+
|
|
54
106
|
return {
|
|
55
107
|
GET: async () => {
|
|
56
108
|
const releases = await deps.queries.listAllReleases();
|
|
57
109
|
return json(releases);
|
|
58
110
|
},
|
|
59
111
|
POST: async (req) => {
|
|
60
|
-
const body = await readJson(req);
|
|
112
|
+
const body = await readJson(req);
|
|
61
113
|
if (!body.ok) return badRequest("Invalid JSON");
|
|
62
114
|
|
|
63
|
-
const parsed =
|
|
115
|
+
const parsed = payloadSchema.safeParse(body.value);
|
|
64
116
|
if (!parsed.success) {
|
|
65
117
|
return badRequest("Validation failed", {
|
|
66
118
|
fieldErrors: flattenIssues(parsed.error),
|
|
67
119
|
});
|
|
68
120
|
}
|
|
121
|
+
|
|
69
122
|
try {
|
|
70
|
-
const release =
|
|
123
|
+
const release = hooksConfigured
|
|
124
|
+
? await createReleaseWithHooks(deps, parsed.data as z.infer<TInput>)
|
|
125
|
+
: await deps.queries.createRelease(parsed.data as unknown as ReleaseInput);
|
|
71
126
|
return json(release, { status: 201 });
|
|
72
127
|
} catch (err) {
|
|
73
128
|
if (isUniqueConstraintError(err)) {
|
|
@@ -87,24 +142,32 @@ const body = await readJson(req);
|
|
|
87
142
|
*
|
|
88
143
|
* Auth: gated by the consumer upstream — see `createAdminReleasesHandlers`.
|
|
89
144
|
*/
|
|
90
|
-
export function createAdminReleaseByIdHandlers
|
|
91
|
-
|
|
145
|
+
export function createAdminReleaseByIdHandlers<
|
|
146
|
+
T extends QueryTables = DefaultTables,
|
|
147
|
+
|
|
148
|
+
TInput extends z.ZodObject<any> = typeof ReleaseInputSchema,
|
|
149
|
+
>(
|
|
150
|
+
deps: ReleasesHandlerDeps<T, TInput> & { storage: StorageProvider },
|
|
92
151
|
): { GET: RouteHandler; PUT: RouteHandler; DELETE: RouteHandler } {
|
|
152
|
+
const inputSchema = (deps.inputSchema ?? ReleaseInputSchema) as TInput;
|
|
153
|
+
const payloadSchema = z.union([inputSchema, DraftReleaseSchema]);
|
|
154
|
+
const hooksConfigured = Boolean(deps.beforeWrite || deps.afterWrite);
|
|
155
|
+
|
|
93
156
|
return {
|
|
94
157
|
GET: async (_req, ctx) => {
|
|
95
|
-
const id = await resolveIdParam(ctx);
|
|
158
|
+
const id = await resolveIdParam(ctx);
|
|
96
159
|
if (id == null) return badRequest("Missing id");
|
|
97
160
|
const all = await deps.queries.listAllReleases();
|
|
98
|
-
const found = all.find((r) => r.id === id);
|
|
161
|
+
const found = all.find((r) => (r as unknown as { id: number }).id === id);
|
|
99
162
|
return found ? json(found) : notFound("Release not found");
|
|
100
163
|
},
|
|
101
164
|
PUT: async (req, ctx) => {
|
|
102
|
-
const id = await resolveIdParam(ctx);
|
|
165
|
+
const id = await resolveIdParam(ctx);
|
|
103
166
|
if (id == null) return badRequest("Missing id");
|
|
104
167
|
|
|
105
168
|
const body = await readJson(req);
|
|
106
169
|
if (!body.ok) return badRequest("Invalid JSON");
|
|
107
|
-
const parsed =
|
|
170
|
+
const parsed = payloadSchema.safeParse(body.value);
|
|
108
171
|
if (!parsed.success) {
|
|
109
172
|
return badRequest("Validation failed", {
|
|
110
173
|
fieldErrors: flattenIssues(parsed.error),
|
|
@@ -112,10 +175,12 @@ const id = await resolveIdParam(ctx);
|
|
|
112
175
|
}
|
|
113
176
|
|
|
114
177
|
try {
|
|
115
|
-
const updated =
|
|
116
|
-
id,
|
|
117
|
-
|
|
118
|
-
|
|
178
|
+
const updated = hooksConfigured
|
|
179
|
+
? await updateReleaseWithHooks(deps, id, parsed.data as z.infer<TInput>)
|
|
180
|
+
: await deps.queries.updateRelease(
|
|
181
|
+
id,
|
|
182
|
+
parsed.data as unknown as Partial<ReleaseInput>,
|
|
183
|
+
);
|
|
119
184
|
return json(updated);
|
|
120
185
|
} catch (err) {
|
|
121
186
|
if (isUniqueConstraintError(err)) {
|
|
@@ -128,16 +193,19 @@ const id = await resolveIdParam(ctx);
|
|
|
128
193
|
}
|
|
129
194
|
},
|
|
130
195
|
DELETE: async (_req, ctx) => {
|
|
131
|
-
const id = await resolveIdParam(ctx);
|
|
196
|
+
const id = await resolveIdParam(ctx);
|
|
132
197
|
if (id == null) return badRequest("Missing id");
|
|
133
198
|
|
|
134
199
|
// Snapshot storage keys before the cascade nukes the rows, so we can
|
|
135
200
|
// clean up the bucket without an orphaned-file scan.
|
|
136
201
|
const all = await deps.queries.listAllReleases();
|
|
137
|
-
const release = all.find((r) => r.id === id);
|
|
202
|
+
const release = all.find((r) => (r as unknown as { id: number }).id === id);
|
|
138
203
|
if (!release) return notFound("Release not found");
|
|
139
204
|
|
|
140
|
-
const keys = collectStorageKeys(
|
|
205
|
+
const keys = collectStorageKeys(
|
|
206
|
+
release as unknown as ReleaseWithFiles,
|
|
207
|
+
deps.storage,
|
|
208
|
+
);
|
|
141
209
|
await deps.queries.deleteRelease(id);
|
|
142
210
|
await Promise.all(
|
|
143
211
|
keys.map((key) =>
|
|
@@ -156,6 +224,68 @@ const id = await resolveIdParam(ctx);
|
|
|
156
224
|
};
|
|
157
225
|
}
|
|
158
226
|
|
|
227
|
+
async function createReleaseWithHooks<
|
|
228
|
+
T extends QueryTables,
|
|
229
|
+
|
|
230
|
+
TInput extends z.ZodObject<any>,
|
|
231
|
+
>(deps: ReleasesHandlerDeps<T, TInput>, input: z.infer<TInput>): Promise<unknown> {
|
|
232
|
+
if (!deps.db || !deps.tables) {
|
|
233
|
+
throw new Error(
|
|
234
|
+
"createAdminReleasesHandlers: beforeWrite/afterWrite require deps.db and deps.tables.",
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
const db = deps.db;
|
|
238
|
+
const tables = deps.tables;
|
|
239
|
+
const releaseId = await db.transaction(async (tx) => {
|
|
240
|
+
const ctx: WriteHookContext = { tx: tx as Tx, logger: deps.logger };
|
|
241
|
+
if (deps.beforeWrite) await deps.beforeWrite(input, ctx);
|
|
242
|
+
const id = await insertReleaseTx(tx as Tx, tables, input as unknown as ReleaseInput);
|
|
243
|
+
if (deps.afterWrite) await deps.afterWrite(id, input, ctx);
|
|
244
|
+
return id;
|
|
245
|
+
});
|
|
246
|
+
// Re-fetch outside the tx so the row includes everything (including extras
|
|
247
|
+
// the consumer wrote via `afterWrite`).
|
|
248
|
+
const slug = (input as { slug?: string }).slug;
|
|
249
|
+
if (slug) {
|
|
250
|
+
const row = await deps.queries.getReleaseBySlug(slug);
|
|
251
|
+
if (row) return row;
|
|
252
|
+
}
|
|
253
|
+
// Fallback — slug missing or row not found by slug; pull via listAll + id.
|
|
254
|
+
const all = await deps.queries.listAllReleases();
|
|
255
|
+
return all.find((r) => (r as unknown as { id: number }).id === releaseId);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function updateReleaseWithHooks<
|
|
259
|
+
T extends QueryTables,
|
|
260
|
+
|
|
261
|
+
TInput extends z.ZodObject<any>,
|
|
262
|
+
>(
|
|
263
|
+
deps: ReleasesHandlerDeps<T, TInput>,
|
|
264
|
+
id: number,
|
|
265
|
+
input: z.infer<TInput>,
|
|
266
|
+
): Promise<unknown> {
|
|
267
|
+
if (!deps.db || !deps.tables) {
|
|
268
|
+
throw new Error(
|
|
269
|
+
"createAdminReleaseByIdHandlers: beforeWrite/afterWrite require deps.db and deps.tables.",
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
const db = deps.db;
|
|
273
|
+
const tables = deps.tables;
|
|
274
|
+
await db.transaction(async (tx) => {
|
|
275
|
+
const ctx: WriteHookContext = { tx: tx as Tx, logger: deps.logger };
|
|
276
|
+
if (deps.beforeWrite) await deps.beforeWrite(input, ctx);
|
|
277
|
+
await updateReleaseTx(
|
|
278
|
+
tx as Tx,
|
|
279
|
+
tables,
|
|
280
|
+
id,
|
|
281
|
+
input as unknown as Partial<ReleaseInput>,
|
|
282
|
+
);
|
|
283
|
+
if (deps.afterWrite) await deps.afterWrite(id, input, ctx);
|
|
284
|
+
});
|
|
285
|
+
const all = await deps.queries.listAllReleases();
|
|
286
|
+
return all.find((r) => (r as unknown as { id: number }).id === id);
|
|
287
|
+
}
|
|
288
|
+
|
|
159
289
|
interface ReleaseWithFiles {
|
|
160
290
|
coverImageUrl: string | null;
|
|
161
291
|
tracks: Array<{ files: Array<{ storageKey: string }> }>;
|
|
@@ -212,3 +342,5 @@ async function readJson(req: Request): Promise<{ ok: true; value: unknown } | {
|
|
|
212
342
|
}
|
|
213
343
|
}
|
|
214
344
|
|
|
345
|
+
// Quiet unused-import lint when AdminDeps isn't needed externally.
|
|
346
|
+
export type { AdminDeps };
|
package/src/handlers/settings.ts
CHANGED
|
@@ -1,9 +1,29 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { DefaultTables, Queries, QueryTables } from "@gigamusic/db";
|
|
3
|
+
import type { AdminLogger, RouteHandler } from "../lib/types";
|
|
2
4
|
import { badRequest, json } from "../lib/responses";
|
|
3
5
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Default settings payload schema — `{ key: string, value: unknown }`. Settings
|
|
8
|
+
* are heterogeneous so the default `value` is permissive; consumers wanting
|
|
9
|
+
* per-key validation pass a `.extend()`ed/`.refine()`d schema as `inputSchema`.
|
|
10
|
+
*/
|
|
11
|
+
export const SettingInputSchema = z.object({
|
|
12
|
+
key: z.string().trim().min(1),
|
|
13
|
+
value: z.unknown(),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export interface SettingsHandlerDeps<
|
|
17
|
+
T extends QueryTables = DefaultTables,
|
|
18
|
+
|
|
19
|
+
TInput extends z.ZodObject<any> = typeof SettingInputSchema,
|
|
20
|
+
> {
|
|
21
|
+
queries: Queries<T>;
|
|
22
|
+
logger?: AdminLogger;
|
|
23
|
+
/** Defaults to `SettingInputSchema`. Pass a tighter schema for per-key validation. */
|
|
24
|
+
inputSchema?: TInput;
|
|
25
|
+
/** Invoked after `queries.setSetting` resolves. Not tx-atomic. */
|
|
26
|
+
afterWrite?: (input: z.infer<TInput>) => Promise<void>;
|
|
7
27
|
}
|
|
8
28
|
|
|
9
29
|
/**
|
|
@@ -14,16 +34,18 @@ interface SettingPayload {
|
|
|
14
34
|
* of truth for serialization (JSON stringification happens inside the db
|
|
15
35
|
* layer).
|
|
16
36
|
*
|
|
17
|
-
* Per-key validation
|
|
18
|
-
* heterogeneous
|
|
19
|
-
* submitting.
|
|
37
|
+
* Per-key validation can be wired via `inputSchema`; settings remain
|
|
38
|
+
* heterogeneous so the default schema accepts any `value`.
|
|
20
39
|
*
|
|
21
40
|
* Auth: the package assumes the request has been gated by the consumer's
|
|
22
41
|
* `proxy.ts` / middleware. These handlers don't verify a session.
|
|
23
42
|
*/
|
|
24
|
-
export function createAdminSettingsHandlers
|
|
25
|
-
|
|
26
|
-
|
|
43
|
+
export function createAdminSettingsHandlers<
|
|
44
|
+
T extends QueryTables = DefaultTables,
|
|
45
|
+
|
|
46
|
+
TInput extends z.ZodObject<any> = typeof SettingInputSchema,
|
|
47
|
+
>(deps: SettingsHandlerDeps<T, TInput>): { GET: RouteHandler; PUT: RouteHandler } {
|
|
48
|
+
const inputSchema = (deps.inputSchema ?? SettingInputSchema) as TInput;
|
|
27
49
|
return {
|
|
28
50
|
GET: async (req) => {
|
|
29
51
|
const url = new URL(req.url);
|
|
@@ -35,16 +57,17 @@ export function createAdminSettingsHandlers(
|
|
|
35
57
|
return json({});
|
|
36
58
|
},
|
|
37
59
|
PUT: async (req) => {
|
|
38
|
-
let body:
|
|
60
|
+
let body: unknown;
|
|
39
61
|
try {
|
|
40
|
-
body =
|
|
62
|
+
body = await req.json();
|
|
41
63
|
} catch {
|
|
42
64
|
return badRequest("Invalid JSON");
|
|
43
65
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
await deps.queries.setSetting(
|
|
66
|
+
const parsed = inputSchema.safeParse(body);
|
|
67
|
+
if (!parsed.success) return badRequest("Validation failed");
|
|
68
|
+
const data = parsed.data as { key: string; value: unknown };
|
|
69
|
+
await deps.queries.setSetting(data.key, data.value);
|
|
70
|
+
if (deps.afterWrite) await deps.afterWrite(parsed.data as z.infer<TInput>);
|
|
48
71
|
return json({ ok: true });
|
|
49
72
|
},
|
|
50
73
|
};
|
package/src/handlers/upload.ts
CHANGED
|
@@ -3,11 +3,15 @@ import { tmpdir } from "node:os";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { writeFile, readFile, unlink, stat } from "node:fs/promises";
|
|
5
5
|
import { createReadStream } from "node:fs";
|
|
6
|
+
import type { DefaultTables, Queries, QueryTables } from "@gigamusic/db";
|
|
6
7
|
import type { AdminDeps, RouteHandler } from "../lib/types";
|
|
7
8
|
import { badRequest, json, serverError } from "../lib/responses";
|
|
8
9
|
|
|
9
10
|
type PresignDeps = Pick<AdminDeps, "storage">;
|
|
10
|
-
type ProcessDeps
|
|
11
|
+
type ProcessDeps<T extends QueryTables = DefaultTables> = Pick<
|
|
12
|
+
AdminDeps<T>,
|
|
13
|
+
"storage" | "audio" | "logger"
|
|
14
|
+
> & { queries: Queries<T> };
|
|
11
15
|
|
|
12
16
|
// Path segments allow the punctuation common in real-world track filenames
|
|
13
17
|
// alongside alphanumerics, dashes, dots, underscores, spaces, parens, and
|
|
@@ -121,7 +125,9 @@ function bufferFromDataUrl(value: unknown): Buffer | null {
|
|
|
121
125
|
* because the track row isn't created yet — the release POST handler
|
|
122
126
|
* persists the files alongside the row).
|
|
123
127
|
*/
|
|
124
|
-
export function createAdminUploadProcessHandler
|
|
128
|
+
export function createAdminUploadProcessHandler<T extends QueryTables = DefaultTables>(
|
|
129
|
+
deps: ProcessDeps<T>,
|
|
130
|
+
): RouteHandler {
|
|
125
131
|
return async (req) => {
|
|
126
132
|
const body = (await safeJson(req)) as ProcessBody;
|
|
127
133
|
const key = typeof body.key === "string" ? body.key : "";
|
package/src/lib/types.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
DefaultTables,
|
|
3
|
+
GigamusicDb,
|
|
4
|
+
Queries,
|
|
5
|
+
QueryTables,
|
|
6
|
+
Tx,
|
|
7
|
+
} from "@gigamusic/db";
|
|
2
8
|
import type { StorageProvider } from "@gigamusic/storage";
|
|
3
9
|
import type * as Audio from "@gigamusic/audio";
|
|
4
10
|
|
|
@@ -18,12 +24,28 @@ export type RouteHandler = (
|
|
|
18
24
|
* bag) into each factory; nothing inside the admin package reads
|
|
19
25
|
* `process.env` directly.
|
|
20
26
|
*
|
|
27
|
+
* Generic over the consumer's `QueryTables` shape so the underlying `Queries`
|
|
28
|
+
* and `beforeWrite`/`afterWrite` hooks see consumer-extended tables when the
|
|
29
|
+
* consumer passed `buildX(extras)` tables in.
|
|
30
|
+
*
|
|
21
31
|
* Auth deliberately isn't here: the package's handlers trust that the
|
|
22
32
|
* request has been gated by the consumer's `proxy.ts` (or middleware /
|
|
23
33
|
* route-level wrapper). See `docs/setup.md` for auth recipes.
|
|
24
34
|
*/
|
|
25
|
-
export interface AdminDeps {
|
|
26
|
-
queries: Queries
|
|
35
|
+
export interface AdminDeps<T extends QueryTables = DefaultTables> {
|
|
36
|
+
queries: Queries<T>;
|
|
37
|
+
/**
|
|
38
|
+
* Raw Drizzle instance — used by handlers that need to open a transaction
|
|
39
|
+
* for the `beforeWrite` / `afterWrite` hooks. Not required when no hooks
|
|
40
|
+
* are configured.
|
|
41
|
+
*/
|
|
42
|
+
db?: GigamusicDb<T>;
|
|
43
|
+
/**
|
|
44
|
+
* The consumer's table identities. Required only when a hook-using handler
|
|
45
|
+
* is constructed so the package can run tx-bound writes against the same
|
|
46
|
+
* tables the consumer's hook will write to.
|
|
47
|
+
*/
|
|
48
|
+
tables?: T;
|
|
27
49
|
storage: StorageProvider;
|
|
28
50
|
audio: typeof Audio;
|
|
29
51
|
/**
|
|
@@ -44,3 +66,14 @@ export const noopLogger: AdminLogger = {
|
|
|
44
66
|
warn() {},
|
|
45
67
|
error() {},
|
|
46
68
|
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Context passed to `beforeWrite` / `afterWrite` hooks. `tx` is the active
|
|
72
|
+
* Drizzle transaction the gigamusic INSERT/UPDATE runs inside — consumer
|
|
73
|
+
* hooks INSERT/UPDATE their own tables through this handle, not through
|
|
74
|
+
* their app's `db` singleton, so failures roll back the gigamusic write.
|
|
75
|
+
*/
|
|
76
|
+
export interface WriteHookContext {
|
|
77
|
+
tx: Tx;
|
|
78
|
+
logger?: AdminLogger;
|
|
79
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -16,6 +16,7 @@ export {
|
|
|
16
16
|
createAdminReleasesHandlers,
|
|
17
17
|
createAdminReleaseByIdHandlers,
|
|
18
18
|
} from "./handlers/releases";
|
|
19
|
+
export type { ReleasesHandlerDeps } from "./handlers/releases";
|
|
19
20
|
export {
|
|
20
21
|
createAdminUploadPresignHandler,
|
|
21
22
|
createAdminUploadProcessHandler,
|
|
@@ -28,4 +29,5 @@ export type {
|
|
|
28
29
|
AdminDeps,
|
|
29
30
|
AdminLogger,
|
|
30
31
|
RouteHandler,
|
|
32
|
+
WriteHookContext,
|
|
31
33
|
} from "./lib/types";
|