@gigamusic/links 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 +4 -3
- package/src/api/handlers.ts +127 -55
- package/src/index.ts +2 -0
- package/src/queries/link-pages.ts +62 -21
- package/src/schema/index.ts +2 -2
- package/src/schema/link-pages.ts +50 -35
- package/src/schema/relations.ts +51 -9
- package/src/types.ts +45 -16
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gigamusic/links",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "Editable Linktree-style link pages for gigamusic artist sites. Ships a Drizzle schema, admin API handler factories, and platform-detection helpers.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -31,8 +31,9 @@
|
|
|
31
31
|
"access": "public"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"
|
|
35
|
-
"@gigamusic/db": "
|
|
34
|
+
"zod": "^3.24.1",
|
|
35
|
+
"@gigamusic/db": "3.0.0",
|
|
36
|
+
"@gigamusic/core": "3.0.0"
|
|
36
37
|
},
|
|
37
38
|
"peerDependencies": {
|
|
38
39
|
"drizzle-orm": ">=0.45",
|
package/src/api/handlers.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { revalidateTag } from "next/cache";
|
|
2
|
-
import
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { LinkPageInputSchema, LinkPageItemInputSchema } from "@gigamusic/core";
|
|
4
|
+
import type {
|
|
5
|
+
DefaultLinkPageTables,
|
|
6
|
+
LinkPageInput,
|
|
7
|
+
LinkPageItemInput,
|
|
8
|
+
LinkPageQueries,
|
|
9
|
+
LinkPageQueryTables,
|
|
10
|
+
} from "../types";
|
|
3
11
|
import {
|
|
4
12
|
INVALID_SLUG_MESSAGE,
|
|
5
13
|
RESERVED_SLUGS,
|
|
@@ -24,11 +32,28 @@ export type RouteHandler<Params = Record<string, string>> = (
|
|
|
24
32
|
/**
|
|
25
33
|
* Deps for the admin-side link-page handler factories.
|
|
26
34
|
*
|
|
35
|
+
* Generic over the consumer's `LinkPageQueryTables` so the `Queries` and the
|
|
36
|
+
* `afterWrite` callbacks see consumer-extended `linkPages` / `linkPageItems`
|
|
37
|
+
* row shapes when the consumer built tables via `buildLinkPages(...)` /
|
|
38
|
+
* `buildLinkPageItems(...)`.
|
|
39
|
+
*
|
|
27
40
|
* Auth: the package assumes the request has been gated by the consumer's
|
|
28
41
|
* `proxy.ts` / middleware. These handlers don't verify a session.
|
|
29
42
|
*/
|
|
30
|
-
export interface LinkPagesAdminDeps
|
|
31
|
-
|
|
43
|
+
export interface LinkPagesAdminDeps<
|
|
44
|
+
T extends LinkPageQueryTables = DefaultLinkPageTables,
|
|
45
|
+
TPageInput extends z.ZodObject<any> = typeof LinkPageInputSchema,
|
|
46
|
+
TItemInput extends z.ZodObject<any> = typeof LinkPageItemInputSchema,
|
|
47
|
+
> {
|
|
48
|
+
queries: LinkPageQueries<T>;
|
|
49
|
+
/** Defaults to `LinkPageInputSchema`. Pass an `.extend()`ed schema for extras. */
|
|
50
|
+
pageInputSchema?: TPageInput;
|
|
51
|
+
/** Defaults to `LinkPageItemInputSchema`. Pass an `.extend()`ed schema for extras. */
|
|
52
|
+
itemInputSchema?: TItemInput;
|
|
53
|
+
/** Invoked after `queries.createLinkPage` / `updateLinkPage` resolves. Not tx-atomic. */
|
|
54
|
+
afterPageWrite?: (pageId: number, input: z.infer<TPageInput>) => Promise<void>;
|
|
55
|
+
/** Invoked after `queries.addLinkPageItem` / `updateLinkPageItem` resolves. Not tx-atomic. */
|
|
56
|
+
afterItemWrite?: (itemId: number, input: z.infer<TItemInput>) => Promise<void>;
|
|
32
57
|
}
|
|
33
58
|
|
|
34
59
|
function json(body: unknown, init?: ResponseInit): Response {
|
|
@@ -41,15 +66,6 @@ function json(body: unknown, init?: ResponseInit): Response {
|
|
|
41
66
|
});
|
|
42
67
|
}
|
|
43
68
|
|
|
44
|
-
interface LinkPageBody {
|
|
45
|
-
title?: string;
|
|
46
|
-
slug?: string;
|
|
47
|
-
description?: string | null;
|
|
48
|
-
releaseId?: number | null;
|
|
49
|
-
coverImageUrl?: string | null;
|
|
50
|
-
isPublished?: boolean;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
69
|
function validateSlug(slug: string): { ok: true } | { ok: false; res: Response } {
|
|
54
70
|
if (!SLUG_PATTERN.test(slug)) {
|
|
55
71
|
return { ok: false, res: json({ error: INVALID_SLUG_MESSAGE }, { status: 400 }) };
|
|
@@ -63,21 +79,30 @@ function validateSlug(slug: string): { ok: true } | { ok: false; res: Response }
|
|
|
63
79
|
/**
|
|
64
80
|
* `app/api/admin/link-pages/route.ts` handlers — list + create.
|
|
65
81
|
*/
|
|
66
|
-
export function createAdminLinkPagesHandlers
|
|
82
|
+
export function createAdminLinkPagesHandlers<
|
|
83
|
+
T extends LinkPageQueryTables = DefaultLinkPageTables,
|
|
84
|
+
TPageInput extends z.ZodObject<any> = typeof LinkPageInputSchema,
|
|
85
|
+
TItemInput extends z.ZodObject<any> = typeof LinkPageItemInputSchema,
|
|
86
|
+
>(deps: LinkPagesAdminDeps<T, TPageInput, TItemInput>): {
|
|
67
87
|
GET: RouteHandler<Record<string, never>>;
|
|
68
88
|
POST: RouteHandler<Record<string, never>>;
|
|
69
89
|
} {
|
|
90
|
+
const pageInputSchema = (deps.pageInputSchema ??
|
|
91
|
+
LinkPageInputSchema) as TPageInput;
|
|
70
92
|
return {
|
|
71
|
-
GET: async (
|
|
72
|
-
const pages = await deps.queries.listAllLinkPages();
|
|
93
|
+
GET: async () => {
|
|
94
|
+
const pages = await deps.queries.listAllLinkPages();
|
|
73
95
|
return json(pages);
|
|
74
96
|
},
|
|
75
97
|
|
|
76
98
|
POST: async (req) => {
|
|
77
|
-
const body =
|
|
78
|
-
const
|
|
79
|
-
|
|
99
|
+
const body = await safeJson(req);
|
|
100
|
+
const parsed = pageInputSchema.safeParse(body);
|
|
101
|
+
if (!parsed.success) return json({ error: "Validation failed" }, { status: 400 });
|
|
80
102
|
|
|
103
|
+
const data = parsed.data as LinkPageInput;
|
|
104
|
+
const title = data.title?.trim();
|
|
105
|
+
const slug = data.slug?.trim().toLowerCase();
|
|
81
106
|
if (!title) return json({ error: "Title is required" }, { status: 400 });
|
|
82
107
|
if (!slug) return json({ error: INVALID_SLUG_MESSAGE }, { status: 400 });
|
|
83
108
|
const slugCheck = validateSlug(slug);
|
|
@@ -87,11 +112,17 @@ const body = (await req.json()) as LinkPageBody;
|
|
|
87
112
|
const page = await deps.queries.createLinkPage({
|
|
88
113
|
title,
|
|
89
114
|
slug,
|
|
90
|
-
description:
|
|
91
|
-
releaseId:
|
|
92
|
-
coverImageUrl:
|
|
93
|
-
isPublished:
|
|
115
|
+
description: data.description ?? null,
|
|
116
|
+
releaseId: data.releaseId ?? null,
|
|
117
|
+
coverImageUrl: data.coverImageUrl ?? null,
|
|
118
|
+
isPublished: data.isPublished ?? true,
|
|
94
119
|
});
|
|
120
|
+
if (deps.afterPageWrite) {
|
|
121
|
+
await deps.afterPageWrite(
|
|
122
|
+
(page as { id: number }).id,
|
|
123
|
+
parsed.data as z.infer<TPageInput>,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
95
126
|
revalidateTag(LINK_PAGES_TAG, "max");
|
|
96
127
|
return json(page, { status: 201 });
|
|
97
128
|
} catch (err) {
|
|
@@ -107,14 +138,22 @@ const body = (await req.json()) as LinkPageBody;
|
|
|
107
138
|
/**
|
|
108
139
|
* `app/api/admin/link-pages/[id]/route.ts` handlers — single-page CRUD.
|
|
109
140
|
*/
|
|
110
|
-
export function createAdminLinkPageByIdHandlers
|
|
141
|
+
export function createAdminLinkPageByIdHandlers<
|
|
142
|
+
T extends LinkPageQueryTables = DefaultLinkPageTables,
|
|
143
|
+
TPageInput extends z.ZodObject<any> = typeof LinkPageInputSchema,
|
|
144
|
+
TItemInput extends z.ZodObject<any> = typeof LinkPageItemInputSchema,
|
|
145
|
+
>(deps: LinkPagesAdminDeps<T, TPageInput, TItemInput>): {
|
|
111
146
|
GET: RouteHandler<{ id: string }>;
|
|
112
147
|
PUT: RouteHandler<{ id: string }>;
|
|
113
148
|
DELETE: RouteHandler<{ id: string }>;
|
|
114
149
|
} {
|
|
150
|
+
const pageInputSchema = (deps.pageInputSchema ??
|
|
151
|
+
LinkPageInputSchema) as TPageInput;
|
|
152
|
+
// Updates accept partials — make every field optional.
|
|
153
|
+
const partialPageSchema = pageInputSchema.partial();
|
|
115
154
|
return {
|
|
116
|
-
GET: async (
|
|
117
|
-
const { id: idParam } = await params;
|
|
155
|
+
GET: async (_req, { params }) => {
|
|
156
|
+
const { id: idParam } = await params;
|
|
118
157
|
const id = Number(idParam);
|
|
119
158
|
if (!Number.isFinite(id)) return json({ error: "Not found" }, { status: 404 });
|
|
120
159
|
const page = await deps.queries.getLinkPageById(id);
|
|
@@ -123,31 +162,36 @@ const { id: idParam } = await params;
|
|
|
123
162
|
},
|
|
124
163
|
|
|
125
164
|
PUT: async (req, { params }) => {
|
|
126
|
-
const { id: idParam } = await params;
|
|
165
|
+
const { id: idParam } = await params;
|
|
127
166
|
const id = Number(idParam);
|
|
128
167
|
if (!Number.isFinite(id)) return json({ error: "Not found" }, { status: 404 });
|
|
129
|
-
const body =
|
|
168
|
+
const body = await safeJson(req);
|
|
169
|
+
const parsed = partialPageSchema.safeParse(body);
|
|
170
|
+
if (!parsed.success) return json({ error: "Validation failed" }, { status: 400 });
|
|
171
|
+
const data = parsed.data as Partial<LinkPageInput>;
|
|
130
172
|
|
|
131
|
-
const update:
|
|
132
|
-
if (
|
|
133
|
-
const title =
|
|
173
|
+
const update: Partial<LinkPageInput> = {};
|
|
174
|
+
if (data.title !== undefined) {
|
|
175
|
+
const title = data.title.trim();
|
|
134
176
|
if (!title) return json({ error: "Title is required" }, { status: 400 });
|
|
135
177
|
update.title = title;
|
|
136
178
|
}
|
|
137
|
-
if (
|
|
138
|
-
const slug =
|
|
179
|
+
if (data.slug !== undefined) {
|
|
180
|
+
const slug = data.slug.trim().toLowerCase();
|
|
139
181
|
const slugCheck = validateSlug(slug);
|
|
140
182
|
if (!slugCheck.ok) return slugCheck.res;
|
|
141
183
|
update.slug = slug;
|
|
142
184
|
}
|
|
143
|
-
if (
|
|
144
|
-
if (
|
|
145
|
-
if (
|
|
146
|
-
|
|
147
|
-
if (body.isPublished !== undefined) update.isPublished = body.isPublished;
|
|
185
|
+
if (data.description !== undefined) update.description = data.description;
|
|
186
|
+
if (data.releaseId !== undefined) update.releaseId = data.releaseId;
|
|
187
|
+
if (data.coverImageUrl !== undefined) update.coverImageUrl = data.coverImageUrl;
|
|
188
|
+
if (data.isPublished !== undefined) update.isPublished = data.isPublished;
|
|
148
189
|
|
|
149
190
|
try {
|
|
150
191
|
const page = await deps.queries.updateLinkPage(id, update);
|
|
192
|
+
if (deps.afterPageWrite) {
|
|
193
|
+
await deps.afterPageWrite(id, parsed.data as z.infer<TPageInput>);
|
|
194
|
+
}
|
|
151
195
|
revalidateTag(LINK_PAGES_TAG, "max");
|
|
152
196
|
return json(page);
|
|
153
197
|
} catch (err) {
|
|
@@ -158,8 +202,8 @@ const { id: idParam } = await params;
|
|
|
158
202
|
}
|
|
159
203
|
},
|
|
160
204
|
|
|
161
|
-
DELETE: async (
|
|
162
|
-
const { id: idParam } = await params;
|
|
205
|
+
DELETE: async (_req, { params }) => {
|
|
206
|
+
const { id: idParam } = await params;
|
|
163
207
|
const id = Number(idParam);
|
|
164
208
|
if (!Number.isFinite(id)) return json({ error: "Not found" }, { status: 404 });
|
|
165
209
|
await deps.queries.deleteLinkPage(id);
|
|
@@ -184,34 +228,52 @@ interface LinkPageItemBody {
|
|
|
184
228
|
* PUT updates a single item by `itemId` in the JSON body.
|
|
185
229
|
* DELETE removes an item by `itemId` in the query string.
|
|
186
230
|
*/
|
|
187
|
-
export function createAdminLinkPageItemsHandlers
|
|
188
|
-
|
|
189
|
-
|
|
231
|
+
export function createAdminLinkPageItemsHandlers<
|
|
232
|
+
T extends LinkPageQueryTables = DefaultLinkPageTables,
|
|
233
|
+
TPageInput extends z.ZodObject<any> = typeof LinkPageInputSchema,
|
|
234
|
+
TItemInput extends z.ZodObject<any> = typeof LinkPageItemInputSchema,
|
|
235
|
+
>(deps: LinkPagesAdminDeps<T, TPageInput, TItemInput>): {
|
|
190
236
|
POST: RouteHandler<{ id: string }>;
|
|
191
237
|
PUT: RouteHandler<{ id: string }>;
|
|
192
238
|
DELETE: RouteHandler<{ id: string }>;
|
|
193
239
|
} {
|
|
240
|
+
const itemInputSchema = (deps.itemInputSchema ??
|
|
241
|
+
LinkPageItemInputSchema) as TItemInput;
|
|
242
|
+
const partialItemSchema = itemInputSchema.partial();
|
|
194
243
|
return {
|
|
195
244
|
POST: async (req, { params }) => {
|
|
196
|
-
const { id: pageIdParam } = await params;
|
|
245
|
+
const { id: pageIdParam } = await params;
|
|
197
246
|
const pageId = Number(pageIdParam);
|
|
198
247
|
if (!Number.isFinite(pageId)) {
|
|
199
248
|
return json({ error: "Not found" }, { status: 404 });
|
|
200
249
|
}
|
|
201
|
-
const
|
|
202
|
-
|
|
250
|
+
const body = await safeJson(req);
|
|
251
|
+
const parsed = itemInputSchema.safeParse(body);
|
|
252
|
+
if (!parsed.success) {
|
|
253
|
+
return json({ error: "Title and URL are required" }, { status: 400 });
|
|
254
|
+
}
|
|
255
|
+
const data = parsed.data as LinkPageItemInput;
|
|
256
|
+
if (!data.title?.trim() || !data.url?.trim()) {
|
|
203
257
|
return json({ error: "Title and URL are required" }, { status: 400 });
|
|
204
258
|
}
|
|
205
259
|
const item = await deps.queries.addLinkPageItem(pageId, {
|
|
206
|
-
title: title.trim(),
|
|
207
|
-
url: url.trim(),
|
|
260
|
+
title: data.title.trim(),
|
|
261
|
+
url: data.url.trim(),
|
|
262
|
+
position: data.position,
|
|
263
|
+
isVisible: data.isVisible,
|
|
208
264
|
});
|
|
265
|
+
if (deps.afterItemWrite) {
|
|
266
|
+
await deps.afterItemWrite(
|
|
267
|
+
(item as { id: number }).id,
|
|
268
|
+
parsed.data as z.infer<TItemInput>,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
209
271
|
revalidateTag(LINK_PAGES_TAG, "max");
|
|
210
272
|
return json(item, { status: 201 });
|
|
211
273
|
},
|
|
212
274
|
|
|
213
275
|
PUT: async (req) => {
|
|
214
|
-
const body = (await req
|
|
276
|
+
const body = (await safeJson(req)) as LinkPageItemBody;
|
|
215
277
|
if (body.itemId == null) {
|
|
216
278
|
return json({ error: "itemId is required" }, { status: 400 });
|
|
217
279
|
}
|
|
@@ -219,18 +281,20 @@ const body = (await req.json()) as LinkPageItemBody;
|
|
|
219
281
|
if (!Number.isFinite(itemId)) {
|
|
220
282
|
return json({ error: "itemId is required" }, { status: 400 });
|
|
221
283
|
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
284
|
+
// Validate the remaining body against the partial-item schema.
|
|
285
|
+
const { itemId: _itemId, ...rest } = body;
|
|
286
|
+
const parsed = partialItemSchema.safeParse(rest);
|
|
287
|
+
if (!parsed.success) return json({ error: "Validation failed" }, { status: 400 });
|
|
288
|
+
const item = await deps.queries.updateLinkPageItem(itemId, parsed.data);
|
|
289
|
+
if (deps.afterItemWrite) {
|
|
290
|
+
await deps.afterItemWrite(itemId, parsed.data as z.infer<TItemInput>);
|
|
291
|
+
}
|
|
228
292
|
revalidateTag(LINK_PAGES_TAG, "max");
|
|
229
293
|
return json(item);
|
|
230
294
|
},
|
|
231
295
|
|
|
232
296
|
DELETE: async (req) => {
|
|
233
|
-
const { searchParams } = new URL(req.url);
|
|
297
|
+
const { searchParams } = new URL(req.url);
|
|
234
298
|
const itemIdRaw = searchParams.get("itemId");
|
|
235
299
|
if (!itemIdRaw) {
|
|
236
300
|
return json({ error: "itemId is required" }, { status: 400 });
|
|
@@ -245,3 +309,11 @@ const { searchParams } = new URL(req.url);
|
|
|
245
309
|
},
|
|
246
310
|
};
|
|
247
311
|
}
|
|
312
|
+
|
|
313
|
+
async function safeJson(req: Request): Promise<unknown> {
|
|
314
|
+
try {
|
|
315
|
+
return await req.json();
|
|
316
|
+
} catch {
|
|
317
|
+
return {};
|
|
318
|
+
}
|
|
319
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
|
|
4
4
|
// ── Types ────────────────────────────────────────────────────────────────
|
|
5
5
|
export type {
|
|
6
|
+
DefaultLinkPageTables,
|
|
6
7
|
LinkPage,
|
|
7
8
|
LinkPageItem,
|
|
8
9
|
LinkPageReleaseRef,
|
|
9
10
|
LinkPageWithItems,
|
|
10
11
|
LinkPageQueries,
|
|
12
|
+
LinkPageQueryTables,
|
|
11
13
|
LinkPageInput,
|
|
12
14
|
LinkPageItemInput,
|
|
13
15
|
} from "./types";
|
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import { and, asc, desc, eq, max } from "drizzle-orm";
|
|
2
2
|
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
3
3
|
import type * as gigamusicDbSchema from "@gigamusic/db/schema";
|
|
4
|
-
import type * as linksSchema from "../schema/index
|
|
5
|
-
import {
|
|
4
|
+
import type * as linksSchema from "../schema/index";
|
|
5
|
+
import {
|
|
6
|
+
linkPageItems as baseLinkPageItems,
|
|
7
|
+
linkPages as baseLinkPages,
|
|
8
|
+
} from "../schema/index";
|
|
6
9
|
import type {
|
|
10
|
+
DefaultLinkPageTables,
|
|
7
11
|
LinkPage,
|
|
8
12
|
LinkPageInput,
|
|
9
13
|
LinkPageItem,
|
|
10
14
|
LinkPageItemInput,
|
|
11
15
|
LinkPageQueries,
|
|
16
|
+
LinkPageQueryTables,
|
|
12
17
|
LinkPageWithItems,
|
|
13
18
|
} from "../types";
|
|
14
19
|
|
|
@@ -21,28 +26,49 @@ export type LinkPagesDb = NodePgDatabase<RequiredSchema>;
|
|
|
21
26
|
* Factory that binds a Drizzle database into the `LinkPageQueries` contract.
|
|
22
27
|
* Consumers typically merge the result with `@gigamusic/db`'s `createQueries`.
|
|
23
28
|
*
|
|
29
|
+
* Pass a `tables` arg to thread consumer-extended `linkPages` / `linkPageItems`
|
|
30
|
+
* (built via the `buildLinkPages` / `buildLinkPageItems` factories) through
|
|
31
|
+
* every query — the returned `LinkPageQueries<T>` rows carry the consumer's
|
|
32
|
+
* extra columns. Single-arg callers fall back to the un-extended convenience
|
|
33
|
+
* tables.
|
|
34
|
+
*
|
|
24
35
|
* The `db` instance must have been built with a schema that includes the
|
|
25
36
|
* union of `@gigamusic/db/schema` and `@gigamusic/links/schema`; otherwise
|
|
26
37
|
* the relational `with: { release: ... }` clause won't resolve at runtime.
|
|
27
38
|
*/
|
|
28
|
-
export function createLinkPageQueries(db: LinkPagesDb): LinkPageQueries
|
|
39
|
+
export function createLinkPageQueries(db: LinkPagesDb): LinkPageQueries;
|
|
40
|
+
export function createLinkPageQueries<T extends LinkPageQueryTables>(
|
|
41
|
+
db: LinkPagesDb,
|
|
42
|
+
tables: T,
|
|
43
|
+
): LinkPageQueries<T>;
|
|
44
|
+
export function createLinkPageQueries<T extends LinkPageQueryTables>(
|
|
45
|
+
db: LinkPagesDb,
|
|
46
|
+
tables?: T,
|
|
47
|
+
): LinkPageQueries<T> {
|
|
48
|
+
// Identity is preserved at runtime; the casts only widen for the type
|
|
49
|
+
// checker so consumer-extended tables don't have to match the base shape
|
|
50
|
+
// exactly.
|
|
51
|
+
const linkPages = (tables?.linkPages ?? baseLinkPages) as unknown as typeof baseLinkPages;
|
|
52
|
+
const linkPageItems = (tables?.linkPageItems ??
|
|
53
|
+
baseLinkPageItems) as unknown as typeof baseLinkPageItems;
|
|
54
|
+
|
|
29
55
|
return {
|
|
30
|
-
listPublishedLinkPages: async (): Promise<LinkPage[]> => {
|
|
56
|
+
listPublishedLinkPages: async (): Promise<LinkPage<T>[]> => {
|
|
31
57
|
const rows = await db.query.linkPages.findMany({
|
|
32
58
|
where: eq(linkPages.isPublished, true),
|
|
33
59
|
orderBy: desc(linkPages.updatedAt),
|
|
34
60
|
});
|
|
35
|
-
return rows;
|
|
61
|
+
return rows as unknown as LinkPage<T>[];
|
|
36
62
|
},
|
|
37
63
|
|
|
38
|
-
listAllLinkPages: async (): Promise<LinkPage[]> => {
|
|
64
|
+
listAllLinkPages: async (): Promise<LinkPage<T>[]> => {
|
|
39
65
|
const rows = await db.query.linkPages.findMany({
|
|
40
66
|
orderBy: desc(linkPages.updatedAt),
|
|
41
67
|
});
|
|
42
|
-
return rows;
|
|
68
|
+
return rows as unknown as LinkPage<T>[];
|
|
43
69
|
},
|
|
44
70
|
|
|
45
|
-
getPublicLinkPageBySlug: async (slug: string): Promise<LinkPageWithItems | null> => {
|
|
71
|
+
getPublicLinkPageBySlug: async (slug: string): Promise<LinkPageWithItems<T> | null> => {
|
|
46
72
|
const row = await db.query.linkPages.findFirst({
|
|
47
73
|
where: and(eq(linkPages.slug, slug), eq(linkPages.isPublished, true)),
|
|
48
74
|
with: {
|
|
@@ -61,10 +87,10 @@ export function createLinkPageQueries(db: LinkPagesDb): LinkPageQueries {
|
|
|
61
87
|
},
|
|
62
88
|
},
|
|
63
89
|
});
|
|
64
|
-
return (row ?? null) as unknown as LinkPageWithItems | null;
|
|
90
|
+
return (row ?? null) as unknown as LinkPageWithItems<T> | null;
|
|
65
91
|
},
|
|
66
92
|
|
|
67
|
-
getLinkPageById: async (id: number): Promise<LinkPageWithItems | null> => {
|
|
93
|
+
getLinkPageById: async (id: number): Promise<LinkPageWithItems<T> | null> => {
|
|
68
94
|
const row = await db.query.linkPages.findFirst({
|
|
69
95
|
where: eq(linkPages.id, id),
|
|
70
96
|
with: {
|
|
@@ -80,10 +106,10 @@ export function createLinkPageQueries(db: LinkPagesDb): LinkPageQueries {
|
|
|
80
106
|
items: { orderBy: asc(linkPageItems.position) },
|
|
81
107
|
},
|
|
82
108
|
});
|
|
83
|
-
return (row ?? null) as unknown as LinkPageWithItems | null;
|
|
109
|
+
return (row ?? null) as unknown as LinkPageWithItems<T> | null;
|
|
84
110
|
},
|
|
85
111
|
|
|
86
|
-
createLinkPage: async (input: LinkPageInput): Promise<LinkPage
|
|
112
|
+
createLinkPage: async (input: LinkPageInput): Promise<LinkPage<T>> => {
|
|
87
113
|
const [row] = await db
|
|
88
114
|
.insert(linkPages)
|
|
89
115
|
.values({
|
|
@@ -95,10 +121,10 @@ export function createLinkPageQueries(db: LinkPagesDb): LinkPageQueries {
|
|
|
95
121
|
isPublished: input.isPublished ?? true,
|
|
96
122
|
})
|
|
97
123
|
.returning();
|
|
98
|
-
return row
|
|
124
|
+
return row! as unknown as LinkPage<T>;
|
|
99
125
|
},
|
|
100
126
|
|
|
101
|
-
updateLinkPage: async (id: number, input: Partial<LinkPageInput>): Promise<LinkPage
|
|
127
|
+
updateLinkPage: async (id: number, input: Partial<LinkPageInput>): Promise<LinkPage<T>> => {
|
|
102
128
|
const data: Record<string, unknown> = {};
|
|
103
129
|
if (input.title !== undefined) data.title = input.title;
|
|
104
130
|
if (input.slug !== undefined) data.slug = input.slug;
|
|
@@ -107,14 +133,17 @@ export function createLinkPageQueries(db: LinkPagesDb): LinkPageQueries {
|
|
|
107
133
|
if (input.coverImageUrl !== undefined) data.coverImageUrl = input.coverImageUrl;
|
|
108
134
|
if (input.isPublished !== undefined) data.isPublished = input.isPublished;
|
|
109
135
|
const [row] = await db.update(linkPages).set(data).where(eq(linkPages.id, id)).returning();
|
|
110
|
-
return row
|
|
136
|
+
return row! as unknown as LinkPage<T>;
|
|
111
137
|
},
|
|
112
138
|
|
|
113
139
|
deleteLinkPage: async (id: number): Promise<void> => {
|
|
114
140
|
await db.delete(linkPages).where(eq(linkPages.id, id));
|
|
115
141
|
},
|
|
116
142
|
|
|
117
|
-
addLinkPageItem: async (
|
|
143
|
+
addLinkPageItem: async (
|
|
144
|
+
pageId: number,
|
|
145
|
+
input: LinkPageItemInput,
|
|
146
|
+
): Promise<LinkPageItem<T>> => {
|
|
118
147
|
const [maxRow] = await db
|
|
119
148
|
.select({ max: max(linkPageItems.position) })
|
|
120
149
|
.from(linkPageItems)
|
|
@@ -130,20 +159,24 @@ export function createLinkPageQueries(db: LinkPagesDb): LinkPageQueries {
|
|
|
130
159
|
isVisible: input.isVisible ?? true,
|
|
131
160
|
})
|
|
132
161
|
.returning();
|
|
133
|
-
return row
|
|
162
|
+
return row! as unknown as LinkPageItem<T>;
|
|
134
163
|
},
|
|
135
164
|
|
|
136
165
|
updateLinkPageItem: async (
|
|
137
166
|
id: number,
|
|
138
167
|
input: Partial<LinkPageItemInput>,
|
|
139
|
-
): Promise<LinkPageItem
|
|
168
|
+
): Promise<LinkPageItem<T>> => {
|
|
140
169
|
const data: Record<string, unknown> = {};
|
|
141
170
|
if (input.title !== undefined) data.title = input.title;
|
|
142
171
|
if (input.url !== undefined) data.url = input.url;
|
|
143
172
|
if (input.position !== undefined) data.position = input.position;
|
|
144
173
|
if (input.isVisible !== undefined) data.isVisible = input.isVisible;
|
|
145
|
-
const [row] = await db
|
|
146
|
-
|
|
174
|
+
const [row] = await db
|
|
175
|
+
.update(linkPageItems)
|
|
176
|
+
.set(data)
|
|
177
|
+
.where(eq(linkPageItems.id, id))
|
|
178
|
+
.returning();
|
|
179
|
+
return row! as unknown as LinkPageItem<T>;
|
|
147
180
|
},
|
|
148
181
|
|
|
149
182
|
deleteLinkPageItem: async (id: number): Promise<void> => {
|
|
@@ -162,9 +195,17 @@ export function createLinkPageQueries(db: LinkPagesDb): LinkPageQueries {
|
|
|
162
195
|
await tx
|
|
163
196
|
.update(linkPageItems)
|
|
164
197
|
.set({ position })
|
|
165
|
-
.where(
|
|
198
|
+
.where(
|
|
199
|
+
and(
|
|
200
|
+
eq(linkPageItems.id, orderedItemIds[position]!),
|
|
201
|
+
eq(linkPageItems.pageId, pageId),
|
|
202
|
+
),
|
|
203
|
+
);
|
|
166
204
|
}
|
|
167
205
|
});
|
|
168
206
|
},
|
|
169
207
|
};
|
|
170
208
|
}
|
|
209
|
+
|
|
210
|
+
// Re-export DefaultLinkPageTables so the consumer can import it if they want.
|
|
211
|
+
export type { DefaultLinkPageTables };
|
package/src/schema/index.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export * from "./link-pages
|
|
2
|
-
export * from "./relations
|
|
1
|
+
export * from "./link-pages";
|
|
2
|
+
export * from "./relations";
|
package/src/schema/link-pages.ts
CHANGED
|
@@ -1,41 +1,56 @@
|
|
|
1
1
|
import { boolean, index, integer, pgTable, serial, text, timestamp } from "drizzle-orm/pg-core";
|
|
2
2
|
import { releases } from "@gigamusic/db/schema";
|
|
3
|
+
import type { ExtraColumns } from "@gigamusic/db/schema";
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
5
|
+
// See @gigamusic/db/schema/releases.ts for the rationale on inlining
|
|
6
|
+
// columns inside the factory (TTableName generic resolution).
|
|
7
|
+
export function buildLinkPages<TExtras extends ExtraColumns = {}>(
|
|
8
|
+
extras: TExtras = {} as TExtras,
|
|
9
|
+
) {
|
|
10
|
+
return pgTable(
|
|
11
|
+
"link_pages",
|
|
12
|
+
{
|
|
13
|
+
id: serial("id").primaryKey(),
|
|
14
|
+
slug: text("slug").notNull().unique("link_pages_slug_key"),
|
|
15
|
+
title: text("title").notNull(),
|
|
16
|
+
description: text("description"),
|
|
17
|
+
coverImageUrl: text("coverImageUrl"),
|
|
18
|
+
releaseId: integer("releaseId").references(() => releases.id, { onDelete: "set null" }),
|
|
19
|
+
isPublished: boolean("isPublished").notNull().default(true),
|
|
20
|
+
createdAt: timestamp("createdAt", { mode: "date", precision: 3 }).notNull().defaultNow(),
|
|
21
|
+
updatedAt: timestamp("updatedAt", { mode: "date", precision: 3 })
|
|
22
|
+
.notNull()
|
|
23
|
+
.$defaultFn(() => new Date())
|
|
24
|
+
.$onUpdateFn(() => new Date()),
|
|
25
|
+
...extras,
|
|
26
|
+
},
|
|
27
|
+
(t) => [index("link_pages_releaseId_idx").on(t.releaseId)],
|
|
28
|
+
);
|
|
29
|
+
}
|
|
18
30
|
|
|
19
|
-
export const linkPages =
|
|
20
|
-
index("link_pages_releaseId_idx").on(t.releaseId),
|
|
21
|
-
]);
|
|
31
|
+
export const linkPages = buildLinkPages();
|
|
22
32
|
|
|
23
|
-
export
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
33
|
+
export function buildLinkPageItems<TExtras extends ExtraColumns = {}>(
|
|
34
|
+
extras: TExtras = {} as TExtras,
|
|
35
|
+
) {
|
|
36
|
+
return pgTable(
|
|
37
|
+
"link_page_items",
|
|
38
|
+
{
|
|
39
|
+
id: serial("id").primaryKey(),
|
|
40
|
+
pageId: integer("pageId").notNull().references(() => linkPages.id, { onDelete: "cascade" }),
|
|
41
|
+
title: text("title").notNull(),
|
|
42
|
+
url: text("url").notNull(),
|
|
43
|
+
position: integer("position").notNull().default(0),
|
|
44
|
+
isVisible: boolean("isVisible").notNull().default(true),
|
|
45
|
+
createdAt: timestamp("createdAt", { mode: "date", precision: 3 }).notNull().defaultNow(),
|
|
46
|
+
updatedAt: timestamp("updatedAt", { mode: "date", precision: 3 })
|
|
47
|
+
.notNull()
|
|
48
|
+
.$defaultFn(() => new Date())
|
|
49
|
+
.$onUpdateFn(() => new Date()),
|
|
50
|
+
...extras,
|
|
51
|
+
},
|
|
52
|
+
(t) => [index("link_page_items_pageId_idx").on(t.pageId)],
|
|
53
|
+
);
|
|
54
|
+
}
|
|
38
55
|
|
|
39
|
-
export const linkPageItems =
|
|
40
|
-
index("link_page_items_pageId_idx").on(t.pageId),
|
|
41
|
-
]);
|
|
56
|
+
export const linkPageItems = buildLinkPageItems();
|
package/src/schema/relations.ts
CHANGED
|
@@ -1,12 +1,54 @@
|
|
|
1
1
|
import { relations } from "drizzle-orm";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import type { PgTable } from "drizzle-orm/pg-core";
|
|
3
|
+
import { releases as baseReleases } from "@gigamusic/db/schema";
|
|
4
|
+
import {
|
|
5
|
+
linkPages as baseLinkPages,
|
|
6
|
+
linkPageItems as baseLinkPageItems,
|
|
7
|
+
} from "./link-pages";
|
|
4
8
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
}));
|
|
9
|
+
// `PgTable<any>` so consumer concrete tables satisfy these factory params —
|
|
10
|
+
// see @gigamusic/db's types.ts for the variance rationale.
|
|
11
|
+
type AnyPgTableValue = PgTable<any>;
|
|
9
12
|
|
|
10
|
-
export
|
|
11
|
-
|
|
12
|
-
|
|
13
|
+
export function createLinkPagesRelations(t: {
|
|
14
|
+
linkPages: AnyPgTableValue;
|
|
15
|
+
linkPageItems: AnyPgTableValue;
|
|
16
|
+
releases: AnyPgTableValue;
|
|
17
|
+
}) {
|
|
18
|
+
const linkPages = t.linkPages as unknown as typeof baseLinkPages;
|
|
19
|
+
const linkPageItems = t.linkPageItems as unknown as typeof baseLinkPageItems;
|
|
20
|
+
const releases = t.releases as unknown as typeof baseReleases;
|
|
21
|
+
return relations(linkPages, ({ one, many }) => ({
|
|
22
|
+
release: one(releases, {
|
|
23
|
+
fields: [linkPages.releaseId],
|
|
24
|
+
references: [releases.id],
|
|
25
|
+
}),
|
|
26
|
+
items: many(linkPageItems),
|
|
27
|
+
}));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createLinkPageItemsRelations(t: {
|
|
31
|
+
linkPages: AnyPgTableValue;
|
|
32
|
+
linkPageItems: AnyPgTableValue;
|
|
33
|
+
}) {
|
|
34
|
+
const linkPages = t.linkPages as unknown as typeof baseLinkPages;
|
|
35
|
+
const linkPageItems = t.linkPageItems as unknown as typeof baseLinkPageItems;
|
|
36
|
+
return relations(linkPageItems, ({ one }) => ({
|
|
37
|
+
page: one(linkPages, {
|
|
38
|
+
fields: [linkPageItems.pageId],
|
|
39
|
+
references: [linkPages.id],
|
|
40
|
+
}),
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Convenience top-level exports bound to the un-extended convenience tables.
|
|
45
|
+
export const linkPagesRelations = createLinkPagesRelations({
|
|
46
|
+
linkPages: baseLinkPages,
|
|
47
|
+
linkPageItems: baseLinkPageItems,
|
|
48
|
+
releases: baseReleases,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export const linkPageItemsRelations = createLinkPageItemsRelations({
|
|
52
|
+
linkPages: baseLinkPages,
|
|
53
|
+
linkPageItems: baseLinkPageItems,
|
|
54
|
+
});
|
package/src/types.ts
CHANGED
|
@@ -1,10 +1,37 @@
|
|
|
1
|
+
import type { PgTable } from "drizzle-orm/pg-core";
|
|
1
2
|
import type { LinkPageInput, LinkPageItemInput } from "@gigamusic/core";
|
|
2
|
-
import type
|
|
3
|
+
import type * as linksSchema from "./schema/index";
|
|
3
4
|
|
|
4
5
|
export type { LinkPageInput, LinkPageItemInput };
|
|
5
6
|
|
|
7
|
+
// `PgTable<any>` (not `PgTable<TableConfig>`) so consumer concrete tables
|
|
8
|
+
// satisfy this constraint — Drizzle's column generics are invariant on
|
|
9
|
+
// column shape; see @gigamusic/db's types.ts for the full rationale.
|
|
10
|
+
type AnyPgTableValue = PgTable<any>;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The bag of Drizzle table identities the link-page queries factory closes
|
|
14
|
+
* over. Consumers can pass extended versions of these tables built via the
|
|
15
|
+
* `buildLinkPages` / `buildLinkPageItems` factories; the `releases` reference
|
|
16
|
+
* is the consumer's `releases` table (for the cover-image fallback join).
|
|
17
|
+
*/
|
|
18
|
+
export interface LinkPageQueryTables {
|
|
19
|
+
linkPages: AnyPgTableValue;
|
|
20
|
+
linkPageItems: AnyPgTableValue;
|
|
21
|
+
releases: AnyPgTableValue;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface DefaultLinkPageTables extends LinkPageQueryTables {
|
|
25
|
+
linkPages: typeof linksSchema.linkPages;
|
|
26
|
+
linkPageItems: typeof linksSchema.linkPageItems;
|
|
27
|
+
// `releases` defaults to the gigamusic convenience table; consumers pass
|
|
28
|
+
// their own extended releases when they have one.
|
|
29
|
+
releases: AnyPgTableValue;
|
|
30
|
+
}
|
|
31
|
+
|
|
6
32
|
/** A single link button rendered inside a `LinkPage`. */
|
|
7
|
-
export type LinkPageItem =
|
|
33
|
+
export type LinkPageItem<T extends LinkPageQueryTables = DefaultLinkPageTables> =
|
|
34
|
+
T["linkPageItems"]["$inferSelect"];
|
|
8
35
|
|
|
9
36
|
/** Optional `Release` reference exposed to the public view for cover fallback. */
|
|
10
37
|
export interface LinkPageReleaseRef {
|
|
@@ -16,32 +43,34 @@ export interface LinkPageReleaseRef {
|
|
|
16
43
|
}
|
|
17
44
|
|
|
18
45
|
/** Top-level "Linktree-style" page row. */
|
|
19
|
-
export type LinkPage =
|
|
46
|
+
export type LinkPage<T extends LinkPageQueryTables = DefaultLinkPageTables> =
|
|
47
|
+
T["linkPages"]["$inferSelect"];
|
|
20
48
|
|
|
21
49
|
/** A `LinkPage` joined with its visible (or all) items and an optional release ref. */
|
|
22
|
-
export
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
50
|
+
export type LinkPageWithItems<T extends LinkPageQueryTables = DefaultLinkPageTables> =
|
|
51
|
+
LinkPage<T> & {
|
|
52
|
+
items: LinkPageItem<T>[];
|
|
53
|
+
release?: LinkPageReleaseRef | null;
|
|
54
|
+
};
|
|
26
55
|
|
|
27
56
|
/**
|
|
28
57
|
* Query helpers that close over a Drizzle DB. Consumers merge this with
|
|
29
58
|
* `@gigamusic/db`'s `Queries` factory result (or pass it through wherever a
|
|
30
59
|
* `LinkPageQueries` is needed).
|
|
31
60
|
*/
|
|
32
|
-
export interface LinkPageQueries {
|
|
33
|
-
listPublishedLinkPages(): Promise<LinkPage[]>;
|
|
34
|
-
listAllLinkPages(): Promise<LinkPage[]>;
|
|
35
|
-
getPublicLinkPageBySlug(slug: string): Promise<LinkPageWithItems | null>;
|
|
36
|
-
getLinkPageById(id: number): Promise<LinkPageWithItems | null>;
|
|
37
|
-
createLinkPage(input: LinkPageInput): Promise<LinkPage
|
|
38
|
-
updateLinkPage(id: number, input: Partial<LinkPageInput>): Promise<LinkPage
|
|
61
|
+
export interface LinkPageQueries<T extends LinkPageQueryTables = DefaultLinkPageTables> {
|
|
62
|
+
listPublishedLinkPages(): Promise<LinkPage<T>[]>;
|
|
63
|
+
listAllLinkPages(): Promise<LinkPage<T>[]>;
|
|
64
|
+
getPublicLinkPageBySlug(slug: string): Promise<LinkPageWithItems<T> | null>;
|
|
65
|
+
getLinkPageById(id: number): Promise<LinkPageWithItems<T> | null>;
|
|
66
|
+
createLinkPage(input: LinkPageInput): Promise<LinkPage<T>>;
|
|
67
|
+
updateLinkPage(id: number, input: Partial<LinkPageInput>): Promise<LinkPage<T>>;
|
|
39
68
|
deleteLinkPage(id: number): Promise<void>;
|
|
40
|
-
addLinkPageItem(pageId: number, input: LinkPageItemInput): Promise<LinkPageItem
|
|
69
|
+
addLinkPageItem(pageId: number, input: LinkPageItemInput): Promise<LinkPageItem<T>>;
|
|
41
70
|
updateLinkPageItem(
|
|
42
71
|
id: number,
|
|
43
72
|
input: Partial<LinkPageItemInput>,
|
|
44
|
-
): Promise<LinkPageItem
|
|
73
|
+
): Promise<LinkPageItem<T>>;
|
|
45
74
|
deleteLinkPageItem(id: number): Promise<void>;
|
|
46
75
|
reorderLinkPageItems(pageId: number, orderedItemIds: number[]): Promise<void>;
|
|
47
76
|
}
|