Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
45 changes: 45 additions & 0 deletions ghost/core/core/server/services/gift-links/database.ts
Original file line number Diff line number Diff line change
@@ -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<typeof DbGiftLink>,
Omit<z.input<typeof DbGiftLink>, 'revoked_at' | 'updated_at'>,
Partial<z.infer<typeof DbGiftLink>>
>;
post_gift_links: Knex.CompositeTableType<
DbPostGiftLink,
Pick<DbPostGiftLink, 'post_id' | 'gift_link_token' | 'created_at'>,
Partial<DbPostGiftLink>
>;
}
}
49 changes: 0 additions & 49 deletions ghost/core/core/server/services/gift-links/model.ts

This file was deleted.

18 changes: 18 additions & 0 deletions ghost/core/core/server/services/gift-links/models.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import {z} from 'zod';

export const GiftLinkToken = z.string().brand('GiftLinkToken');
export type GiftLinkToken = z.infer<typeof GiftLinkToken>;

export const GiftLink = z.object({
token: GiftLinkToken,
redeemedCount: z.number().int().nonnegative(),
lastRedeemedAt: z.date().nullable(),
createdAt: z.date()
});
export type GiftLink = z.infer<typeof GiftLink>;

/** The gift-links aggregate of a post and its live links; distinct from the Bookshelf Post model. */
export interface Post {
id: string;
giftLinks: GiftLink[];
}
57 changes: 26 additions & 31 deletions ghost/core/core/server/services/gift-links/queries.ts
Original file line number Diff line number Diff line change
@@ -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<typeof GiftLinkRow> & {post_id: string},
Partial<GiftLinkTableRow>
>;
post_gift_links: Knex.CompositeTableType<
PostGiftLinkTableRow,
Pick<PostGiftLinkTableRow, 'post_id' | 'gift_link_token' | 'created_at'>,
Partial<PostGiftLinkTableRow>
>;
}
}
// 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
Expand Down
19 changes: 7 additions & 12 deletions ghost/core/core/server/services/gift-links/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -21,8 +21,8 @@ export class GiftLinksService {
}

async getPostByToken(token: string): Promise<Post | null> {
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<Post> {
Expand Down Expand Up @@ -58,21 +58,16 @@ export class GiftLinksService {
.increment('redeemed_count', 1);
}

// Binds an executor-agnostic read statement to this service's connection.
private run<T>(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<Post> {
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<typeof GiftLinkRow> => row.token !== null)
.map(row => z.decode(giftLinkCodec, row));
.filter((row): row is z.input<typeof queries.GiftLinkRow> => row.token !== null)
.map(row => z.decode(queries.giftLinkCodec, row));
return {id: postId, giftLinks};
}

Expand All @@ -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')
Expand Down
2 changes: 1 addition & 1 deletion ghost/core/test/integration/services/gift-links.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading