diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/gift-links.ts b/ghost/core/core/server/api/endpoints/utils/serializers/output/gift-links.ts index 6311490b100..5b8646dfd11 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/output/gift-links.ts +++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/gift-links.ts @@ -1,6 +1,6 @@ import {z} from 'zod'; -import {GiftLink} from '../../../../../services/gift-links/model'; -import type {Post} from '../../../../../services/gift-links/model'; +import {GiftLink} from '../../../../../services/gift-links/models'; +import type {Post} from '../../../../../services/gift-links/models'; interface Frame { response?: unknown; diff --git a/ghost/core/core/server/services/gift-links/database.ts b/ghost/core/core/server/services/gift-links/database.ts new file mode 100644 index 00000000000..83e142dbd41 --- /dev/null +++ b/ghost/core/core/server/services/gift-links/database.ts @@ -0,0 +1,45 @@ +import {z} from 'zod'; +import type {Knex} from 'knex'; + +// MySQL returns a Date, SQLite a string/number; normalise to Date on read. +// Lives here with the row schema that uses it until a second table needs it. +const DbDate = z.codec(z.union([z.date(), z.string(), z.number()]), z.date(), { + decode: value => new Date(value), + encode: date => date +}); + +// The gift_links table row: the single source for the read projection (queries.ts) and the knex +// types below. +export const DbGiftLink = z.object({ + token: z.string(), + post_id: z.string(), + redeemed_count: z.number().int().nonnegative(), + last_redeemed_at: DbDate.nullable(), + revoked_at: DbDate.nullable(), + created_at: DbDate, + updated_at: DbDate.nullable() +}); + +// The post_gift_links association carries no domain object, so it stays a plain row type. +interface DbPostGiftLink { + post_id: string; + gift_link_token: string; + created_at: Date; + updated_at: Date | null; +} + +// knex table types, derived from the schemas above so each row shape has a single source. +declare module 'knex/types/tables' { + interface Tables { + gift_links: Knex.CompositeTableType< + z.infer, + Omit, 'revoked_at' | 'updated_at'>, + Partial> + >; + post_gift_links: Knex.CompositeTableType< + DbPostGiftLink, + Pick, + Partial + >; + } +} diff --git a/ghost/core/core/server/services/gift-links/model.ts b/ghost/core/core/server/services/gift-links/model.ts deleted file mode 100644 index 9d3ebc42eb9..00000000000 --- a/ghost/core/core/server/services/gift-links/model.ts +++ /dev/null @@ -1,49 +0,0 @@ -import {z} from 'zod'; - -export const GiftLinkToken = z.string().brand('GiftLinkToken'); -export type GiftLinkToken = z.infer; - -// MySQL returns a Date, SQLite a string/number; normalise to Date on read. -const DbDate = z.codec(z.union([z.date(), z.string(), z.number()]), z.date(), { - decode: value => new Date(value), - encode: date => date -}); - -export const GiftLinkRow = z.object({ - // Unbranded here; the codec brands it into a GiftLinkToken on decode. - token: z.string(), - redeemed_count: z.number().int().nonnegative(), - last_redeemed_at: DbDate.nullable(), - created_at: DbDate -}); - -export const GiftLink = z.object({ - token: GiftLinkToken, - redeemedCount: z.number().int().nonnegative(), - lastRedeemedAt: z.date().nullable(), - createdAt: z.date() -}); -export type GiftLink = z.infer; - -export const giftLinkCodec = z.codec(GiftLinkRow, GiftLink, { - decode: row => ({ - token: row.token, - redeemedCount: row.redeemed_count, - lastRedeemedAt: row.last_redeemed_at, - createdAt: row.created_at - }), - encode: link => ({ - token: link.token, - redeemed_count: link.redeemedCount, - last_redeemed_at: link.lastRedeemedAt, - created_at: link.createdAt - }) -}); - -export const GIFT_LINK_COLUMNS = Object.keys(GiftLinkRow.shape); - -/** The gift-links aggregate of a post and its live links; distinct from the Bookshelf Post model. */ -export interface Post { - id: string; - giftLinks: GiftLink[]; -} diff --git a/ghost/core/core/server/services/gift-links/models.ts b/ghost/core/core/server/services/gift-links/models.ts new file mode 100644 index 00000000000..7bd766be152 --- /dev/null +++ b/ghost/core/core/server/services/gift-links/models.ts @@ -0,0 +1,18 @@ +import {z} from 'zod'; + +export const GiftLinkToken = z.string().brand('GiftLinkToken'); +export type GiftLinkToken = z.infer; + +export const GiftLink = z.object({ + token: GiftLinkToken, + redeemedCount: z.number().int().nonnegative(), + lastRedeemedAt: z.date().nullable(), + createdAt: z.date() +}); +export type GiftLink = z.infer; + +/** The gift-links aggregate of a post and its live links; distinct from the Bookshelf Post model. */ +export interface Post { + id: string; + giftLinks: GiftLink[]; +} diff --git a/ghost/core/core/server/services/gift-links/queries.ts b/ghost/core/core/server/services/gift-links/queries.ts index ac72236716b..3e35ab0a71b 100644 --- a/ghost/core/core/server/services/gift-links/queries.ts +++ b/ghost/core/core/server/services/gift-links/queries.ts @@ -1,38 +1,33 @@ import {z} from 'zod'; import type {Knex} from 'knex'; -import {GIFT_LINK_COLUMNS, GiftLinkRow} from './model'; +import {DbGiftLink} from './database'; +import {GiftLink} from './models'; -interface GiftLinkTableRow { - token: string; - post_id: string; - redeemed_count: number; - last_redeemed_at: Date | null; - revoked_at: Date | null; - created_at: Date; - updated_at: Date | null; -} -interface PostGiftLinkTableRow { - post_id: string; - gift_link_token: string; - created_at: Date; - updated_at: Date | null; -} -declare module 'knex/types/tables' { - interface Tables { - gift_links: Knex.CompositeTableType< - GiftLinkTableRow, - z.input & {post_id: string}, - Partial - >; - post_gift_links: Knex.CompositeTableType< - PostGiftLinkTableRow, - Pick, - Partial - >; - } -} +// The columns the read path selects and the codec decodes into a GiftLink. +export const GiftLinkRow = DbGiftLink.pick({ + token: true, + redeemed_count: true, + last_redeemed_at: true, + created_at: true +}); + +// Maps a selected row to/from the domain GiftLink (snake_case to camelCase, token branding). +export const giftLinkCodec = z.codec(GiftLinkRow, GiftLink, { + decode: row => ({ + token: row.token, + redeemedCount: row.redeemed_count, + lastRedeemedAt: row.last_redeemed_at, + createdAt: row.created_at + }), + encode: link => ({ + token: link.token, + redeemed_count: link.redeemedCount, + last_redeemed_at: link.lastRedeemedAt, + created_at: link.createdAt + }) +}); -const giftLinkColumns = GIFT_LINK_COLUMNS.map(column => `gift_links.${column}`); +const giftLinkColumns = Object.keys(GiftLinkRow.shape).map(column => `gift_links.${column}`); // Executor-agnostic statements for the read shapes (joins, filters, columns): each is // parameterised by domain args and takes the connection at execution, so the service binds diff --git a/ghost/core/core/server/services/gift-links/service.ts b/ghost/core/core/server/services/gift-links/service.ts index 91b82dd32d7..bf115acdf48 100644 --- a/ghost/core/core/server/services/gift-links/service.ts +++ b/ghost/core/core/server/services/gift-links/service.ts @@ -2,7 +2,7 @@ import crypto from 'crypto'; import errors from '@tryghost/errors'; import {z} from 'zod'; import type {Knex} from 'knex'; -import {GiftLinkRow, GiftLinkToken, giftLinkCodec, type GiftLink, type Post} from './model'; +import {GiftLinkToken, type GiftLink, type Post} from './models'; import * as queries from './queries'; export function generateGiftLinkToken(): GiftLinkToken { @@ -21,8 +21,8 @@ export class GiftLinksService { } async getPostByToken(token: string): Promise { - const row = await this.run(queries.liveLinkForToken(token)); - return row ? {id: row.post_id, giftLinks: [z.decode(giftLinkCodec, row)]} : null; + const row = await queries.liveLinkForToken(token)(this.knex); + return row ? {id: row.post_id, giftLinks: [z.decode(queries.giftLinkCodec, row)]} : null; } async issue(postId: string): Promise { @@ -58,21 +58,16 @@ export class GiftLinksService { .increment('redeemed_count', 1); } - // Binds an executor-agnostic read statement to this service's connection. - private run(statement: (knex: Knex) => T): T { - return statement(this.knex); - } - // A missing post is a 404, not a post with no live links: no rows means no post, and the // remaining rows carrying a token are the live links. private async requirePost(postId: string): Promise { - const rows = await this.run(queries.liveLinksForPost(postId)); + const rows = await queries.liveLinksForPost(postId)(this.knex); if (rows.length === 0) { throw new errors.NotFoundError({message: `Post ${postId} does not exist.`}); } const giftLinks = rows - .filter((row): row is z.input => row.token !== null) - .map(row => z.decode(giftLinkCodec, row)); + .filter((row): row is z.input => row.token !== null) + .map(row => z.decode(queries.giftLinkCodec, row)); return {id: postId, giftLinks}; } @@ -86,7 +81,7 @@ export class GiftLinksService { if (replacing) { await trx('gift_links').where({token: replacing}).update({revoked_at: now, updated_at: now}); } - await trx('gift_links').insert({...z.encode(giftLinkCodec, link), post_id: postId}); + await trx('gift_links').insert({...z.encode(queries.giftLinkCodec, link), post_id: postId}); await trx('post_gift_links') .insert({post_id: postId, gift_link_token: link.token, created_at: now}) .onConflict('post_id') diff --git a/ghost/core/test/integration/services/gift-links.test.ts b/ghost/core/test/integration/services/gift-links.test.ts index ebc11c88ffc..5e5405d3d94 100644 --- a/ghost/core/test/integration/services/gift-links.test.ts +++ b/ghost/core/test/integration/services/gift-links.test.ts @@ -2,7 +2,7 @@ import {afterAll, afterEach, beforeAll, describe, it} from 'vitest'; import assert from 'node:assert/strict'; import errors from '@tryghost/errors'; import {GiftLinksService} from '../../../core/server/services/gift-links/service'; -import type {GiftLink} from '../../../core/server/services/gift-links/model'; +import type {GiftLink} from '../../../core/server/services/gift-links/models'; const testUtils = require('../../utils'); const models = require('../../../core/server/models');