@gigamusic/links 2.1.1 → 4.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/README.md CHANGED
@@ -1,14 +1,16 @@
1
1
  # @gigamusic/links
2
2
 
3
- Linktree-style per-release link pages for the gigamusic platform. Ships a Drizzle schema (`linkPages`, `linkPageItems`), a typed queries factory, admin API handler factories, platform-detection helpers, and a small SVG `LinkPlatformIcon` component.
3
+ Linktree-style per-release link pages for the gigamusic platform. Ships admin API handler factories, platform-detection helpers, and a small SVG `LinkPlatformIcon` component.
4
+
5
+ The `link_pages` / `link_page_items` Drizzle schema **and** the link-page queries live in `@gigamusic/db` — they're folded into the single `createQueries(db)` so a consumer builds one query object for every table. This package re-exports the link-page row types for convenience.
4
6
 
5
7
  ```ts
6
8
  import {
7
- createLinkPageQueries,
8
9
  detectLinkPlatform,
9
10
  PLATFORM_LABELS,
10
11
  LinkPlatformIcon,
11
12
  type KnownLinkPlatform,
13
+ type LinkPageWithItems,
12
14
  } from "@gigamusic/links";
13
15
 
14
16
  // Admin handler factories live on the /server subpath
@@ -19,43 +21,32 @@ import {
19
21
  } from "@gigamusic/links/server";
20
22
  ```
21
23
 
22
- The public link-page UI is the consumer's responsibility — fetch with `createLinkPageQueries(db).getPublicLinkPageBySlug(slug)` and render whatever you want.
23
-
24
- ## Schema subpath
24
+ ## Reads and writes
25
25
 
26
- `@gigamusic/links/schema` exports the table objects and relations:
26
+ All link-page DB access comes from `@gigamusic/db`'s queries:
27
27
 
28
28
  ```ts
29
- import {
30
- linkPages,
31
- linkPageItems,
32
- linkPagesRelations,
33
- linkPageItemsRelations,
34
- linkPageColumns,
35
- linkPageItemColumns,
36
- } from "@gigamusic/links/schema";
29
+ import { createQueries } from "@gigamusic/db";
30
+
31
+ const queries = createQueries(db); // one object, every table
32
+
33
+ // Public link-page UI — fetch and render whatever you want:
34
+ const page = await queries.getPublicLinkPageBySlug(slug);
37
35
  ```
38
36
 
39
- Merge with `@gigamusic/db/schema` when constructing your Drizzle instance:
37
+ The admin handler factories from `@gigamusic/links/server` take that same `queries` object:
40
38
 
41
39
  ```ts
42
- import { drizzle } from "drizzle-orm/node-postgres";
43
- import { Pool } from "pg";
44
- import * as dbSchema from "@gigamusic/db/schema";
45
- import * as linksSchema from "@gigamusic/links/schema";
46
-
47
- const schema = { ...dbSchema, ...linksSchema };
48
- export const db = drizzle(new Pool({ connectionString: process.env.DATABASE_URL }), { schema });
40
+ const handlers = createAdminLinkPagesHandlers({ queries });
49
41
  ```
50
42
 
51
43
  ## Release back-reference
52
44
 
53
- `linkPages.releaseId` is a nullable FK to `releases.id` from `@gigamusic/db`. Drizzle relations are declared independently on each side, so this package's `linkPagesRelations` already declares the `linkPage.release` direction. If you want a `release.linkPages` back-reference (for `db.query.releases.findFirst({ with: { linkPages: true } })`), add it in your own schema file by composing onto the base `releasesRelations`:
45
+ `linkPages.releaseId` is a nullable FK to `releases.id`. Both tables and their relations live in `@gigamusic/db/schema`; `linkPagesRelations` already declares the `linkPage.release` direction. If you want a `release.linkPages` back-reference (for `db.query.releases.findFirst({ with: { linkPages: true } })`), compose it onto the base `releasesRelations` in your own schema file:
54
46
 
55
47
  ```ts
56
48
  import { relations } from "drizzle-orm";
57
- import { releases } from "@gigamusic/db/schema";
58
- import { linkPages } from "@gigamusic/links/schema";
49
+ import { releases, linkPages } from "@gigamusic/db/schema";
59
50
 
60
51
  export const releasesRelations = relations(releases, ({ many }) => ({
61
52
  // ...keep the existing many() relations from @gigamusic/db/schema if you need them...
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gigamusic/links",
3
- "version": "2.1.1",
4
- "description": "Editable Linktree-style link pages for gigamusic artist sites. Ships a Drizzle schema, admin API handler factories, and platform-detection helpers.",
3
+ "version": "4.0.0",
4
+ "description": "Editable Linktree-style link pages for gigamusic artist sites. Ships admin API handler factories and platform-detection helpers (the Drizzle schema + queries live in @gigamusic/db).",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -17,10 +17,6 @@
17
17
  "./server": {
18
18
  "types": "./src/server.ts",
19
19
  "default": "./src/server.ts"
20
- },
21
- "./schema": {
22
- "types": "./src/schema/index.ts",
23
- "default": "./src/schema/index.ts"
24
20
  }
25
21
  },
26
22
  "files": [
@@ -31,23 +27,19 @@
31
27
  "access": "public"
32
28
  },
33
29
  "dependencies": {
34
- "@gigamusic/core": "1.0.0",
35
- "@gigamusic/db": "2.1.0"
30
+ "zod": "^3.24.1",
31
+ "@gigamusic/core": "3.0.0",
32
+ "@gigamusic/db": "4.0.0"
36
33
  },
37
34
  "peerDependencies": {
38
- "drizzle-orm": ">=0.45",
39
35
  "next": ">=15",
40
36
  "react": ">=18 <20",
41
37
  "react-dom": ">=18 <20"
42
38
  },
43
39
  "devDependencies": {
44
40
  "@types/node": "^22.10.5",
45
- "@types/pg": "^8.11.10",
46
41
  "@types/react": "^19.0.0",
47
- "drizzle-kit": "^0.31.10",
48
- "drizzle-orm": "^0.45.2",
49
42
  "next": "^16.0.0",
50
- "pg": "^8.21.0",
51
43
  "react": "^19.0.0",
52
44
  "typescript": "^5.7.3",
53
45
  "vitest": "^2.1.8"
@@ -1,5 +1,8 @@
1
1
  import { revalidateTag } from "next/cache";
2
- import type { LinkPageQueries } from "../types";
2
+ import { z } from "zod";
3
+ import { LinkPageInputSchema, LinkPageItemInputSchema } from "@gigamusic/core";
4
+ import type { LinkPageInput, LinkPageItemInput } from "@gigamusic/core";
5
+ import type { DefaultTables, Queries, QueryTables } from "@gigamusic/db";
3
6
  import {
4
7
  INVALID_SLUG_MESSAGE,
5
8
  RESERVED_SLUGS,
@@ -24,11 +27,28 @@ export type RouteHandler<Params = Record<string, string>> = (
24
27
  /**
25
28
  * Deps for the admin-side link-page handler factories.
26
29
  *
30
+ * Generic over the consumer's `QueryTables` so the `Queries` and the
31
+ * `afterWrite` callbacks see consumer-extended `linkPages` / `linkPageItems`
32
+ * row shapes when the consumer built tables via `buildLinkPages(...)` /
33
+ * `buildLinkPageItems(...)`.
34
+ *
27
35
  * Auth: the package assumes the request has been gated by the consumer's
28
36
  * `proxy.ts` / middleware. These handlers don't verify a session.
29
37
  */
30
- export interface LinkPagesAdminDeps {
31
- queries: LinkPageQueries;
38
+ export interface LinkPagesAdminDeps<
39
+ T extends QueryTables = DefaultTables,
40
+ TPageInput extends z.ZodObject<any> = typeof LinkPageInputSchema,
41
+ TItemInput extends z.ZodObject<any> = typeof LinkPageItemInputSchema,
42
+ > {
43
+ queries: Queries<T>;
44
+ /** Defaults to `LinkPageInputSchema`. Pass an `.extend()`ed schema for extras. */
45
+ pageInputSchema?: TPageInput;
46
+ /** Defaults to `LinkPageItemInputSchema`. Pass an `.extend()`ed schema for extras. */
47
+ itemInputSchema?: TItemInput;
48
+ /** Invoked after `queries.createLinkPage` / `updateLinkPage` resolves. Not tx-atomic. */
49
+ afterPageWrite?: (pageId: number, input: z.infer<TPageInput>) => Promise<void>;
50
+ /** Invoked after `queries.addLinkPageItem` / `updateLinkPageItem` resolves. Not tx-atomic. */
51
+ afterItemWrite?: (itemId: number, input: z.infer<TItemInput>) => Promise<void>;
32
52
  }
33
53
 
34
54
  function json(body: unknown, init?: ResponseInit): Response {
@@ -41,15 +61,6 @@ function json(body: unknown, init?: ResponseInit): Response {
41
61
  });
42
62
  }
43
63
 
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
64
  function validateSlug(slug: string): { ok: true } | { ok: false; res: Response } {
54
65
  if (!SLUG_PATTERN.test(slug)) {
55
66
  return { ok: false, res: json({ error: INVALID_SLUG_MESSAGE }, { status: 400 }) };
@@ -63,21 +74,30 @@ function validateSlug(slug: string): { ok: true } | { ok: false; res: Response }
63
74
  /**
64
75
  * `app/api/admin/link-pages/route.ts` handlers — list + create.
65
76
  */
66
- export function createAdminLinkPagesHandlers(deps: LinkPagesAdminDeps): {
77
+ export function createAdminLinkPagesHandlers<
78
+ T extends QueryTables = DefaultTables,
79
+ TPageInput extends z.ZodObject<any> = typeof LinkPageInputSchema,
80
+ TItemInput extends z.ZodObject<any> = typeof LinkPageItemInputSchema,
81
+ >(deps: LinkPagesAdminDeps<T, TPageInput, TItemInput>): {
67
82
  GET: RouteHandler<Record<string, never>>;
68
83
  POST: RouteHandler<Record<string, never>>;
69
84
  } {
85
+ const pageInputSchema = (deps.pageInputSchema ??
86
+ LinkPageInputSchema) as TPageInput;
70
87
  return {
71
- GET: async (_req) => {
72
- const pages = await deps.queries.listAllLinkPages();
88
+ GET: async () => {
89
+ const pages = await deps.queries.listAllLinkPages();
73
90
  return json(pages);
74
91
  },
75
92
 
76
93
  POST: async (req) => {
77
- const body = (await req.json()) as LinkPageBody;
78
- const title = body.title?.trim();
79
- const slug = body.slug?.trim().toLowerCase();
94
+ const body = await safeJson(req);
95
+ const parsed = pageInputSchema.safeParse(body);
96
+ if (!parsed.success) return json({ error: "Validation failed" }, { status: 400 });
80
97
 
98
+ const data = parsed.data as LinkPageInput;
99
+ const title = data.title?.trim();
100
+ const slug = data.slug?.trim().toLowerCase();
81
101
  if (!title) return json({ error: "Title is required" }, { status: 400 });
82
102
  if (!slug) return json({ error: INVALID_SLUG_MESSAGE }, { status: 400 });
83
103
  const slugCheck = validateSlug(slug);
@@ -87,11 +107,17 @@ const body = (await req.json()) as LinkPageBody;
87
107
  const page = await deps.queries.createLinkPage({
88
108
  title,
89
109
  slug,
90
- description: body.description ?? null,
91
- releaseId: body.releaseId ?? null,
92
- coverImageUrl: body.coverImageUrl ?? null,
93
- isPublished: body.isPublished ?? true,
110
+ description: data.description ?? null,
111
+ releaseId: data.releaseId ?? null,
112
+ coverImageUrl: data.coverImageUrl ?? null,
113
+ isPublished: data.isPublished ?? true,
94
114
  });
115
+ if (deps.afterPageWrite) {
116
+ await deps.afterPageWrite(
117
+ (page as { id: number }).id,
118
+ parsed.data as z.infer<TPageInput>,
119
+ );
120
+ }
95
121
  revalidateTag(LINK_PAGES_TAG, "max");
96
122
  return json(page, { status: 201 });
97
123
  } catch (err) {
@@ -107,14 +133,22 @@ const body = (await req.json()) as LinkPageBody;
107
133
  /**
108
134
  * `app/api/admin/link-pages/[id]/route.ts` handlers — single-page CRUD.
109
135
  */
110
- export function createAdminLinkPageByIdHandlers(deps: LinkPagesAdminDeps): {
136
+ export function createAdminLinkPageByIdHandlers<
137
+ T extends QueryTables = DefaultTables,
138
+ TPageInput extends z.ZodObject<any> = typeof LinkPageInputSchema,
139
+ TItemInput extends z.ZodObject<any> = typeof LinkPageItemInputSchema,
140
+ >(deps: LinkPagesAdminDeps<T, TPageInput, TItemInput>): {
111
141
  GET: RouteHandler<{ id: string }>;
112
142
  PUT: RouteHandler<{ id: string }>;
113
143
  DELETE: RouteHandler<{ id: string }>;
114
144
  } {
145
+ const pageInputSchema = (deps.pageInputSchema ??
146
+ LinkPageInputSchema) as TPageInput;
147
+ // Updates accept partials — make every field optional.
148
+ const partialPageSchema = pageInputSchema.partial();
115
149
  return {
116
- GET: async (req, { params }) => {
117
- const { id: idParam } = await params;
150
+ GET: async (_req, { params }) => {
151
+ const { id: idParam } = await params;
118
152
  const id = Number(idParam);
119
153
  if (!Number.isFinite(id)) return json({ error: "Not found" }, { status: 404 });
120
154
  const page = await deps.queries.getLinkPageById(id);
@@ -123,31 +157,36 @@ const { id: idParam } = await params;
123
157
  },
124
158
 
125
159
  PUT: async (req, { params }) => {
126
- const { id: idParam } = await params;
160
+ const { id: idParam } = await params;
127
161
  const id = Number(idParam);
128
162
  if (!Number.isFinite(id)) return json({ error: "Not found" }, { status: 404 });
129
- const body = (await req.json()) as LinkPageBody;
163
+ const body = await safeJson(req);
164
+ const parsed = partialPageSchema.safeParse(body);
165
+ if (!parsed.success) return json({ error: "Validation failed" }, { status: 400 });
166
+ const data = parsed.data as Partial<LinkPageInput>;
130
167
 
131
- const update: LinkPageBody = {};
132
- if (body.title !== undefined) {
133
- const title = body.title.trim();
168
+ const update: Partial<LinkPageInput> = {};
169
+ if (data.title !== undefined) {
170
+ const title = data.title.trim();
134
171
  if (!title) return json({ error: "Title is required" }, { status: 400 });
135
172
  update.title = title;
136
173
  }
137
- if (body.slug !== undefined) {
138
- const slug = body.slug.trim().toLowerCase();
174
+ if (data.slug !== undefined) {
175
+ const slug = data.slug.trim().toLowerCase();
139
176
  const slugCheck = validateSlug(slug);
140
177
  if (!slugCheck.ok) return slugCheck.res;
141
178
  update.slug = slug;
142
179
  }
143
- if (body.description !== undefined) update.description = body.description;
144
- if (body.releaseId !== undefined) update.releaseId = body.releaseId;
145
- if (body.coverImageUrl !== undefined)
146
- update.coverImageUrl = body.coverImageUrl;
147
- if (body.isPublished !== undefined) update.isPublished = body.isPublished;
180
+ if (data.description !== undefined) update.description = data.description;
181
+ if (data.releaseId !== undefined) update.releaseId = data.releaseId;
182
+ if (data.coverImageUrl !== undefined) update.coverImageUrl = data.coverImageUrl;
183
+ if (data.isPublished !== undefined) update.isPublished = data.isPublished;
148
184
 
149
185
  try {
150
186
  const page = await deps.queries.updateLinkPage(id, update);
187
+ if (deps.afterPageWrite) {
188
+ await deps.afterPageWrite(id, parsed.data as z.infer<TPageInput>);
189
+ }
151
190
  revalidateTag(LINK_PAGES_TAG, "max");
152
191
  return json(page);
153
192
  } catch (err) {
@@ -158,8 +197,8 @@ const { id: idParam } = await params;
158
197
  }
159
198
  },
160
199
 
161
- DELETE: async (req, { params }) => {
162
- const { id: idParam } = await params;
200
+ DELETE: async (_req, { params }) => {
201
+ const { id: idParam } = await params;
163
202
  const id = Number(idParam);
164
203
  if (!Number.isFinite(id)) return json({ error: "Not found" }, { status: 404 });
165
204
  await deps.queries.deleteLinkPage(id);
@@ -184,34 +223,52 @@ interface LinkPageItemBody {
184
223
  * PUT updates a single item by `itemId` in the JSON body.
185
224
  * DELETE removes an item by `itemId` in the query string.
186
225
  */
187
- export function createAdminLinkPageItemsHandlers(
188
- deps: LinkPagesAdminDeps,
189
- ): {
226
+ export function createAdminLinkPageItemsHandlers<
227
+ T extends QueryTables = DefaultTables,
228
+ TPageInput extends z.ZodObject<any> = typeof LinkPageInputSchema,
229
+ TItemInput extends z.ZodObject<any> = typeof LinkPageItemInputSchema,
230
+ >(deps: LinkPagesAdminDeps<T, TPageInput, TItemInput>): {
190
231
  POST: RouteHandler<{ id: string }>;
191
232
  PUT: RouteHandler<{ id: string }>;
192
233
  DELETE: RouteHandler<{ id: string }>;
193
234
  } {
235
+ const itemInputSchema = (deps.itemInputSchema ??
236
+ LinkPageItemInputSchema) as TItemInput;
237
+ const partialItemSchema = itemInputSchema.partial();
194
238
  return {
195
239
  POST: async (req, { params }) => {
196
- const { id: pageIdParam } = await params;
240
+ const { id: pageIdParam } = await params;
197
241
  const pageId = Number(pageIdParam);
198
242
  if (!Number.isFinite(pageId)) {
199
243
  return json({ error: "Not found" }, { status: 404 });
200
244
  }
201
- const { title, url } = (await req.json()) as { title?: string; url?: string };
202
- if (!title?.trim() || !url?.trim()) {
245
+ const body = await safeJson(req);
246
+ const parsed = itemInputSchema.safeParse(body);
247
+ if (!parsed.success) {
248
+ return json({ error: "Title and URL are required" }, { status: 400 });
249
+ }
250
+ const data = parsed.data as LinkPageItemInput;
251
+ if (!data.title?.trim() || !data.url?.trim()) {
203
252
  return json({ error: "Title and URL are required" }, { status: 400 });
204
253
  }
205
254
  const item = await deps.queries.addLinkPageItem(pageId, {
206
- title: title.trim(),
207
- url: url.trim(),
255
+ title: data.title.trim(),
256
+ url: data.url.trim(),
257
+ position: data.position,
258
+ isVisible: data.isVisible,
208
259
  });
260
+ if (deps.afterItemWrite) {
261
+ await deps.afterItemWrite(
262
+ (item as { id: number }).id,
263
+ parsed.data as z.infer<TItemInput>,
264
+ );
265
+ }
209
266
  revalidateTag(LINK_PAGES_TAG, "max");
210
267
  return json(item, { status: 201 });
211
268
  },
212
269
 
213
270
  PUT: async (req) => {
214
- const body = (await req.json()) as LinkPageItemBody;
271
+ const body = (await safeJson(req)) as LinkPageItemBody;
215
272
  if (body.itemId == null) {
216
273
  return json({ error: "itemId is required" }, { status: 400 });
217
274
  }
@@ -219,18 +276,20 @@ const body = (await req.json()) as LinkPageItemBody;
219
276
  if (!Number.isFinite(itemId)) {
220
277
  return json({ error: "itemId is required" }, { status: 400 });
221
278
  }
222
- const item = await deps.queries.updateLinkPageItem(itemId, {
223
- title: body.title,
224
- url: body.url,
225
- position: body.position,
226
- isVisible: body.isVisible,
227
- });
279
+ // Validate the remaining body against the partial-item schema.
280
+ const { itemId: _itemId, ...rest } = body;
281
+ const parsed = partialItemSchema.safeParse(rest);
282
+ if (!parsed.success) return json({ error: "Validation failed" }, { status: 400 });
283
+ const item = await deps.queries.updateLinkPageItem(itemId, parsed.data);
284
+ if (deps.afterItemWrite) {
285
+ await deps.afterItemWrite(itemId, parsed.data as z.infer<TItemInput>);
286
+ }
228
287
  revalidateTag(LINK_PAGES_TAG, "max");
229
288
  return json(item);
230
289
  },
231
290
 
232
291
  DELETE: async (req) => {
233
- const { searchParams } = new URL(req.url);
292
+ const { searchParams } = new URL(req.url);
234
293
  const itemIdRaw = searchParams.get("itemId");
235
294
  if (!itemIdRaw) {
236
295
  return json({ error: "itemId is required" }, { status: 400 });
@@ -245,3 +304,11 @@ const { searchParams } = new URL(req.url);
245
304
  },
246
305
  };
247
306
  }
307
+
308
+ async function safeJson(req: Request): Promise<unknown> {
309
+ try {
310
+ return await req.json();
311
+ } catch {
312
+ return {};
313
+ }
314
+ }
package/src/index.ts CHANGED
@@ -1,20 +1,16 @@
1
- // Public entry — server-safe types, queries, and platform helpers.
2
- // Admin handler factories live on the `./server` subpath.
1
+ // Public entry — platform helpers + UI, plus a convenience re-export of the
2
+ // link-page DB types. The queries themselves come from `@gigamusic/db`'s
3
+ // `createQueries(db, site)`; admin handler factories live on `./server`.
3
4
 
4
- // ── Types ────────────────────────────────────────────────────────────────
5
+ // ── Types (re-exported from @gigamusic/db, the single source for DB types) ─
5
6
  export type {
6
7
  LinkPage,
7
8
  LinkPageItem,
8
9
  LinkPageReleaseRef,
9
10
  LinkPageWithItems,
10
- LinkPageQueries,
11
11
  LinkPageInput,
12
12
  LinkPageItemInput,
13
- } from "./types";
14
-
15
- // ── Queries factory ─────────────────────────────────────────────────────
16
- export { createLinkPageQueries } from "./queries/link-pages";
17
- export type { LinkPagesDb } from "./queries/link-pages";
13
+ } from "@gigamusic/db";
18
14
 
19
15
  // ── Platform detection ──────────────────────────────────────────────────
20
16
  export { detectLinkPlatform, PLATFORM_LABELS } from "./platforms/detect";
package/src/server.ts CHANGED
@@ -1,9 +1,7 @@
1
- // Server-only entry — admin API handler factories and the queries factory.
2
- // Importing from here in a `"use client"` file will cause a build error
3
- // because the handlers depend on `next/cache`.
1
+ // Server-only entry — admin API handler factories. Importing from here in a
2
+ // `"use client"` file will cause a build error because the handlers depend on
3
+ // `next/cache`. (The query factory lives in `@gigamusic/db`'s `createQueries`.)
4
4
 
5
- export { createLinkPageQueries } from "./queries/link-pages";
6
- export type { LinkPagesDb } from "./queries/link-pages";
7
5
  export {
8
6
  createAdminLinkPagesHandlers,
9
7
  createAdminLinkPageByIdHandlers,
@@ -1,170 +0,0 @@
1
- import { and, asc, desc, eq, max } from "drizzle-orm";
2
- import type { NodePgDatabase } from "drizzle-orm/node-postgres";
3
- import type * as gigamusicDbSchema from "@gigamusic/db/schema";
4
- import type * as linksSchema from "../schema/index";
5
- import { linkPageItems, linkPages } from "../schema/index";
6
- import type {
7
- LinkPage,
8
- LinkPageInput,
9
- LinkPageItem,
10
- LinkPageItemInput,
11
- LinkPageQueries,
12
- LinkPageWithItems,
13
- } from "../types";
14
-
15
- type RequiredSchema = typeof gigamusicDbSchema & typeof linksSchema;
16
-
17
- /** Drizzle DB type the link-pages factory needs — a superset that includes both link-pages tables and `releases`. */
18
- export type LinkPagesDb = NodePgDatabase<RequiredSchema>;
19
-
20
- /**
21
- * Factory that binds a Drizzle database into the `LinkPageQueries` contract.
22
- * Consumers typically merge the result with `@gigamusic/db`'s `createQueries`.
23
- *
24
- * The `db` instance must have been built with a schema that includes the
25
- * union of `@gigamusic/db/schema` and `@gigamusic/links/schema`; otherwise
26
- * the relational `with: { release: ... }` clause won't resolve at runtime.
27
- */
28
- export function createLinkPageQueries(db: LinkPagesDb): LinkPageQueries {
29
- return {
30
- listPublishedLinkPages: async (): Promise<LinkPage[]> => {
31
- const rows = await db.query.linkPages.findMany({
32
- where: eq(linkPages.isPublished, true),
33
- orderBy: desc(linkPages.updatedAt),
34
- });
35
- return rows;
36
- },
37
-
38
- listAllLinkPages: async (): Promise<LinkPage[]> => {
39
- const rows = await db.query.linkPages.findMany({
40
- orderBy: desc(linkPages.updatedAt),
41
- });
42
- return rows;
43
- },
44
-
45
- getPublicLinkPageBySlug: async (slug: string): Promise<LinkPageWithItems | null> => {
46
- const row = await db.query.linkPages.findFirst({
47
- where: and(eq(linkPages.slug, slug), eq(linkPages.isPublished, true)),
48
- with: {
49
- release: {
50
- columns: {
51
- id: true,
52
- name: true,
53
- slug: true,
54
- coverImageUrl: true,
55
- isPublished: true,
56
- },
57
- },
58
- items: {
59
- where: eq(linkPageItems.isVisible, true),
60
- orderBy: asc(linkPageItems.position),
61
- },
62
- },
63
- });
64
- return (row ?? null) as unknown as LinkPageWithItems | null;
65
- },
66
-
67
- getLinkPageById: async (id: number): Promise<LinkPageWithItems | null> => {
68
- const row = await db.query.linkPages.findFirst({
69
- where: eq(linkPages.id, id),
70
- with: {
71
- release: {
72
- columns: {
73
- id: true,
74
- name: true,
75
- slug: true,
76
- coverImageUrl: true,
77
- isPublished: true,
78
- },
79
- },
80
- items: { orderBy: asc(linkPageItems.position) },
81
- },
82
- });
83
- return (row ?? null) as unknown as LinkPageWithItems | null;
84
- },
85
-
86
- createLinkPage: async (input: LinkPageInput): Promise<LinkPage> => {
87
- const [row] = await db
88
- .insert(linkPages)
89
- .values({
90
- title: input.title,
91
- slug: input.slug,
92
- description: input.description ?? null,
93
- releaseId: input.releaseId ?? null,
94
- coverImageUrl: input.coverImageUrl ?? null,
95
- isPublished: input.isPublished ?? true,
96
- })
97
- .returning();
98
- return row!;
99
- },
100
-
101
- updateLinkPage: async (id: number, input: Partial<LinkPageInput>): Promise<LinkPage> => {
102
- const data: Record<string, unknown> = {};
103
- if (input.title !== undefined) data.title = input.title;
104
- if (input.slug !== undefined) data.slug = input.slug;
105
- if (input.description !== undefined) data.description = input.description;
106
- if (input.releaseId !== undefined) data.releaseId = input.releaseId;
107
- if (input.coverImageUrl !== undefined) data.coverImageUrl = input.coverImageUrl;
108
- if (input.isPublished !== undefined) data.isPublished = input.isPublished;
109
- const [row] = await db.update(linkPages).set(data).where(eq(linkPages.id, id)).returning();
110
- return row!;
111
- },
112
-
113
- deleteLinkPage: async (id: number): Promise<void> => {
114
- await db.delete(linkPages).where(eq(linkPages.id, id));
115
- },
116
-
117
- addLinkPageItem: async (pageId: number, input: LinkPageItemInput): Promise<LinkPageItem> => {
118
- const [maxRow] = await db
119
- .select({ max: max(linkPageItems.position) })
120
- .from(linkPageItems)
121
- .where(eq(linkPageItems.pageId, pageId));
122
- const nextPosition = input.position ?? ((maxRow?.max ?? -1) + 1);
123
- const [row] = await db
124
- .insert(linkPageItems)
125
- .values({
126
- pageId,
127
- title: input.title,
128
- url: input.url,
129
- position: nextPosition,
130
- isVisible: input.isVisible ?? true,
131
- })
132
- .returning();
133
- return row!;
134
- },
135
-
136
- updateLinkPageItem: async (
137
- id: number,
138
- input: Partial<LinkPageItemInput>,
139
- ): Promise<LinkPageItem> => {
140
- const data: Record<string, unknown> = {};
141
- if (input.title !== undefined) data.title = input.title;
142
- if (input.url !== undefined) data.url = input.url;
143
- if (input.position !== undefined) data.position = input.position;
144
- if (input.isVisible !== undefined) data.isVisible = input.isVisible;
145
- const [row] = await db.update(linkPageItems).set(data).where(eq(linkPageItems.id, id)).returning();
146
- return row!;
147
- },
148
-
149
- deleteLinkPageItem: async (id: number): Promise<void> => {
150
- await db.delete(linkPageItems).where(eq(linkPageItems.id, id));
151
- },
152
-
153
- /**
154
- * Rewrite every item's `position` to match the caller's preferred order
155
- * inside a single transaction. IDs not present in `orderedItemIds` keep
156
- * their current position — callers must include the full set to fully
157
- * resort.
158
- */
159
- reorderLinkPageItems: async (pageId: number, orderedItemIds: number[]): Promise<void> => {
160
- await db.transaction(async (tx) => {
161
- for (let position = 0; position < orderedItemIds.length; position++) {
162
- await tx
163
- .update(linkPageItems)
164
- .set({ position })
165
- .where(and(eq(linkPageItems.id, orderedItemIds[position]!), eq(linkPageItems.pageId, pageId)));
166
- }
167
- });
168
- },
169
- };
170
- }
@@ -1,2 +0,0 @@
1
- export * from "./link-pages";
2
- export * from "./relations";
@@ -1,41 +0,0 @@
1
- import { boolean, index, integer, pgTable, serial, text, timestamp } from "drizzle-orm/pg-core";
2
- import { releases } from "@gigamusic/db/schema";
3
-
4
- export const linkPageColumns = {
5
- id: serial("id").primaryKey(),
6
- slug: text("slug").notNull().unique("link_pages_slug_key"),
7
- title: text("title").notNull(),
8
- description: text("description"),
9
- coverImageUrl: text("coverImageUrl"),
10
- releaseId: integer("releaseId").references(() => releases.id, { onDelete: "set null" }),
11
- isPublished: boolean("isPublished").notNull().default(true),
12
- createdAt: timestamp("createdAt", { mode: "date", precision: 3 }).notNull().defaultNow(),
13
- updatedAt: timestamp("updatedAt", { mode: "date", precision: 3 })
14
- .notNull()
15
- .$defaultFn(() => new Date())
16
- .$onUpdateFn(() => new Date()),
17
- } as const;
18
-
19
- export const linkPages = pgTable("link_pages", linkPageColumns, (t) => [
20
- index("link_pages_releaseId_idx").on(t.releaseId),
21
- ]);
22
-
23
- export const linkPageItemColumns = {
24
- id: serial("id").primaryKey(),
25
- pageId: integer("pageId")
26
- .notNull()
27
- .references(() => linkPages.id, { onDelete: "cascade" }),
28
- title: text("title").notNull(),
29
- url: text("url").notNull(),
30
- position: integer("position").notNull().default(0),
31
- isVisible: boolean("isVisible").notNull().default(true),
32
- createdAt: timestamp("createdAt", { mode: "date", precision: 3 }).notNull().defaultNow(),
33
- updatedAt: timestamp("updatedAt", { mode: "date", precision: 3 })
34
- .notNull()
35
- .$defaultFn(() => new Date())
36
- .$onUpdateFn(() => new Date()),
37
- } as const;
38
-
39
- export const linkPageItems = pgTable("link_page_items", linkPageItemColumns, (t) => [
40
- index("link_page_items_pageId_idx").on(t.pageId),
41
- ]);
@@ -1,12 +0,0 @@
1
- import { relations } from "drizzle-orm";
2
- import { releases } from "@gigamusic/db/schema";
3
- import { linkPages, linkPageItems } from "./link-pages";
4
-
5
- export const linkPagesRelations = relations(linkPages, ({ one, many }) => ({
6
- release: one(releases, { fields: [linkPages.releaseId], references: [releases.id] }),
7
- items: many(linkPageItems),
8
- }));
9
-
10
- export const linkPageItemsRelations = relations(linkPageItems, ({ one }) => ({
11
- page: one(linkPages, { fields: [linkPageItems.pageId], references: [linkPages.id] }),
12
- }));
package/src/types.ts DELETED
@@ -1,47 +0,0 @@
1
- import type { LinkPageInput, LinkPageItemInput } from "@gigamusic/core";
2
- import type { linkPageItems, linkPages } from "./schema/index";
3
-
4
- export type { LinkPageInput, LinkPageItemInput };
5
-
6
- /** A single link button rendered inside a `LinkPage`. */
7
- export type LinkPageItem = typeof linkPageItems.$inferSelect;
8
-
9
- /** Optional `Release` reference exposed to the public view for cover fallback. */
10
- export interface LinkPageReleaseRef {
11
- id: number;
12
- name: string;
13
- slug: string;
14
- coverImageUrl: string | null;
15
- isPublished: boolean;
16
- }
17
-
18
- /** Top-level "Linktree-style" page row. */
19
- export type LinkPage = typeof linkPages.$inferSelect;
20
-
21
- /** A `LinkPage` joined with its visible (or all) items and an optional release ref. */
22
- export interface LinkPageWithItems extends LinkPage {
23
- items: LinkPageItem[];
24
- release?: LinkPageReleaseRef | null;
25
- }
26
-
27
- /**
28
- * Query helpers that close over a Drizzle DB. Consumers merge this with
29
- * `@gigamusic/db`'s `Queries` factory result (or pass it through wherever a
30
- * `LinkPageQueries` is needed).
31
- */
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>;
39
- deleteLinkPage(id: number): Promise<void>;
40
- addLinkPageItem(pageId: number, input: LinkPageItemInput): Promise<LinkPageItem>;
41
- updateLinkPageItem(
42
- id: number,
43
- input: Partial<LinkPageItemInput>,
44
- ): Promise<LinkPageItem>;
45
- deleteLinkPageItem(id: number): Promise<void>;
46
- reorderLinkPageItems(pageId: number, orderedItemIds: number[]): Promise<void>;
47
- }