Skip to content

Commit f4a20bc

Browse files
committed
Changed gift-links service to split CQRS queries and commands
Separated the gift-links read path (GiftLinkQueries) from the write path (GiftLinkCommands) so reads and mutations no longer share one class, and extracted action recording and the row codec into their own modules. The entry controller now resolves tokens through queries directly, keeping the read seam free of command/action dependencies.
1 parent a97342a commit f4a20bc

19 files changed

Lines changed: 257 additions & 388 deletions

File tree

ghost/core/core/frontend/services/proxy.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,8 @@ module.exports = {
7070

7171
// Labs utils for enabling/disabling helpers
7272
labs: require('../../shared/labs'),
73-
// Gift links service — reached through this seam so the entry controller
74-
// doesn't require a server module directly.
73+
// Reached through this seam so the entry controller doesn't require a server
74+
// module directly.
7575
giftLinks: require('../../server/services/gift-links'),
7676
// Paid-member shim for gift-link reads (shared with previews). Lazy getter so
7777
// the members service resolves at call time, avoiding boot-time require-order

ghost/core/core/frontend/services/routing/controllers/entry/gift-links.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,8 @@ function strippedGiftUrl(req: Request): string {
2020
}
2121

2222
/**
23-
* Flag the render as a gift view so `ghost_foot` injects the toast. Stores the
24-
* token (not just a boolean) for a later analytics pass-through. Internal flag,
25-
* not a public theme API.
23+
* Flag the render as a gift view so `ghost_foot` injects the toast. Internal
24+
* flag, not a public theme API.
2625
*/
2726
function setGiftTemplateFlag(res: EntryResponse, token: string): void {
2827
const localTemplateOptions = hbs.getLocalTemplateOptions(res.locals);
@@ -80,7 +79,7 @@ async function renderUnlocked(req: Request, res: EntryResponse, token: string) {
8079
export async function serveGiftRequest(req: Request, res: EntryResponse, entry: Entry) {
8180
const token = giftToken(req);
8281

83-
if (token && await proxy.giftLinks.service.isValidTokenForPost(token, entry.id)) {
82+
if (token && await proxy.giftLinks.queries.isValidTokenForPost(token, entry.id)) {
8483
return renderUnlocked(req, res, token);
8584
}
8685

ghost/core/core/server/api/endpoints/gift-links.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {service, type RequestContext} from '../../services/gift-links';
1+
import {queries, commands, type RequestContext} from '../../services/gift-links';
22

33
const permissionsService = require('../../services/permissions');
44

@@ -40,7 +40,7 @@ const controller = {
4040
return assertCanEditAndGift(frame);
4141
},
4242
query(frame: Frame) {
43-
return service!.getPost(frame.options.id);
43+
return queries!.getPost(frame.options.id);
4444
}
4545
},
4646

@@ -53,7 +53,7 @@ const controller = {
5353
return assertCanEditAndGift(frame);
5454
},
5555
query(frame: Frame) {
56-
return service!.ensure(requestContextFromFrame(frame), frame.options.id);
56+
return commands!.ensure(requestContextFromFrame(frame), frame.options.id);
5757
}
5858
},
5959

@@ -66,7 +66,7 @@ const controller = {
6666
return assertCanEditAndGift(frame);
6767
},
6868
query(frame: Frame) {
69-
return service!.create(requestContextFromFrame(frame), frame.options.id);
69+
return commands!.create(requestContextFromFrame(frame), frame.options.id);
7070
}
7171
},
7272

@@ -77,7 +77,7 @@ const controller = {
7777
return permissionsService.canThis(frame.options.context).removeAll.gift_link();
7878
},
7979
async query(frame: Frame) {
80-
const count = await service!.removeAll(requestContextFromFrame(frame));
80+
const count = await commands!.removeAll(requestContextFromFrame(frame));
8181
return {count};
8282
}
8383
}

ghost/core/core/server/api/endpoints/utils/serializers/output/gift-links.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,7 @@ const serializeGiftLinks = (post: Post, _apiConfig: unknown, frame: Frame): void
99
frame.response = toGiftLinksResponse.parse(post.giftLinks);
1010
};
1111

12-
// module.exports (not export): the API framework loads serializers via require(). The endpoint ->
13-
// serializer mapping lives here; the response shaping lives with the gift-links service module.
12+
// module.exports (not export): the API framework loads serializers via require().
1413
module.exports = {
1514
browse: serializeGiftLinks,
1615
ensure: serializeGiftLinks,
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import logging from '@tryghost/logging';
2+
3+
export interface Actor {
4+
id: string;
5+
type: 'user' | 'integration';
6+
}
7+
8+
export interface RequestContext {
9+
actor: Actor | null;
10+
}
11+
12+
export interface ActionRecorder {
13+
add(data: Record<string, unknown>, options: {autoRefresh: boolean}): Promise<unknown>;
14+
}
15+
16+
// The history UI only surfaces a verb-specific label (action_name) for 'edited' events; 'added' and
17+
// 'deleted' render as the bare event. So 'reset' maps to 'edited' to read as "reset", while
18+
// 'add'/'remove' read as plain "added"/"deleted".
19+
const COMMANDS = {
20+
add: 'added',
21+
reset: 'edited',
22+
remove: 'deleted'
23+
} as const satisfies Record<string, 'added' | 'edited' | 'deleted'>;
24+
25+
export type GiftLinkVerb = keyof typeof COMMANDS;
26+
27+
export type RecordGiftLinkAction =
28+
(input: {context: RequestContext; verb: GiftLinkVerb; subject: string | null}) => Promise<void>;
29+
30+
// Best-effort action-log write: a failed action must never fail the command that triggered it.
31+
export async function recordGiftLinkAction(
32+
{Action, context, verb, subject}:
33+
{Action: ActionRecorder; context: RequestContext; verb: GiftLinkVerb; subject: string | null}
34+
): Promise<void> {
35+
if (!context.actor) {
36+
return;
37+
}
38+
const event = COMMANDS[verb];
39+
try {
40+
await Action.add({
41+
event,
42+
resource_type: 'gift_link',
43+
resource_id: subject,
44+
actor_type: context.actor.type,
45+
actor_id: context.actor.id,
46+
...(event === 'edited' ? {context: {action_name: verb}} : {})
47+
}, {autoRefresh: false});
48+
} catch (err) {
49+
logging.error(err);
50+
}
51+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import {z} from 'zod';
2+
import {camelKeys, snakeKeys} from './case-keys';
3+
import {DbGiftLink} from './schema';
4+
import {GiftLink} from './models';
5+
6+
export const GiftLinkRow = DbGiftLink.pick({
7+
token: true,
8+
created_at: true
9+
});
10+
11+
export const giftLinkCodec = z.codec(GiftLinkRow, GiftLink, {
12+
decode: row => camelKeys(row),
13+
encode: link => snakeKeys(link)
14+
});
15+
16+
export const giftLinkColumns = Object.keys(GiftLinkRow.shape).map(column => `gift_links.${column}`);
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import {z} from 'zod';
2+
import type {Knex} from 'knex';
3+
import {giftLinkCodec} from './codec';
4+
import {generateGiftLinkToken, type GiftLink, type Post} from './models';
5+
import {type GiftLinkQueries} from './queries';
6+
import {type RecordGiftLinkAction, type RequestContext} from './actions';
7+
8+
export class GiftLinkCommands {
9+
private knex: Knex;
10+
private queries: GiftLinkQueries;
11+
private recordAction: RecordGiftLinkAction;
12+
13+
constructor({knex, queries, recordAction}: {knex: Knex; queries: GiftLinkQueries; recordAction: RecordGiftLinkAction}) {
14+
this.knex = knex;
15+
this.queries = queries;
16+
this.recordAction = recordAction;
17+
}
18+
19+
async ensure(context: RequestContext, postId: string): Promise<Post> {
20+
const post = await this.queries.getPost(postId);
21+
if (post.giftLinks.length) {
22+
return post;
23+
}
24+
const minted = await this.mint(postId);
25+
await this.recordAction({context, verb: 'add', subject: postId});
26+
return minted;
27+
}
28+
29+
async create(context: RequestContext, postId: string): Promise<Post> {
30+
await this.queries.getPost(postId); // asserts the post exists (throws NotFound)
31+
const minted = await this.mint(postId);
32+
await this.recordAction({context, verb: 'reset', subject: postId});
33+
return minted;
34+
}
35+
36+
// gift_links rows are kept as history; only the live association is removed.
37+
async removeAll(context: RequestContext): Promise<number> {
38+
const removed = await this.knex('post_gift_links').del();
39+
if (removed > 0) {
40+
await this.recordAction({context, verb: 'remove', subject: null});
41+
}
42+
return removed;
43+
}
44+
45+
private async mint(postId: string): Promise<Post> {
46+
const now = new Date();
47+
const link: GiftLink = {token: generateGiftLinkToken(), createdAt: now};
48+
await this.knex.transaction(async (trx) => {
49+
await trx('gift_links').insert({...z.encode(giftLinkCodec, link), post_id: postId});
50+
await trx('post_gift_links')
51+
.insert({post_id: postId, gift_link_token: link.token, created_at: now})
52+
.onConflict('post_id')
53+
.merge({gift_link_token: link.token, updated_at: now});
54+
});
55+
return {id: postId, giftLinks: [link]};
56+
}
57+
}
Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,23 @@
1-
import {GiftLinksService} from './service';
1+
import {GiftLinkQueries} from './queries';
2+
import {GiftLinkCommands} from './commands';
3+
import {recordGiftLinkAction, type RecordGiftLinkAction} from './actions';
24

3-
export type {RequestContext} from './service';
5+
export type {RequestContext} from './actions';
46

5-
// Set by init() at boot, not at import: knex only exists once the DB has connected.
6-
export let service: GiftLinksService | undefined;
7+
// Constructed by init() at boot, not at import: knex is only available once the DB has connected.
8+
export let queries: GiftLinkQueries | undefined;
9+
export let commands: GiftLinkCommands | undefined;
710

811
export function init(): void {
9-
if (service) {
12+
if (queries && commands) {
1013
return;
1114
}
1215

1316
const {knex} = require('../../data/db');
1417
const models = require('../../models');
1518

16-
service = new GiftLinksService({knex, Action: models.Action});
19+
queries = new GiftLinkQueries({knex});
20+
const recordAction: RecordGiftLinkAction = ({context, verb, subject}) =>
21+
recordGiftLinkAction({Action: models.Action, context, verb, subject});
22+
commands = new GiftLinkCommands({knex, queries, recordAction});
1723
}

ghost/core/core/server/services/gift-links/models.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
1+
import crypto from 'crypto';
12
import {z} from 'zod';
23

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

7+
// 24 random bytes (192 bits of entropy); base64url keeps it URL-safe.
8+
export function generateGiftLinkToken(): GiftLinkToken {
9+
return GiftLinkToken.parse(crypto.randomBytes(24).toString('base64url'));
10+
}
11+
612
export const GiftLink = z.object({
713
token: GiftLinkToken,
814
createdAt: z.date()
915
});
1016
export type GiftLink = z.infer<typeof GiftLink>;
1117

12-
/** The gift-links aggregate of a post and its live links; distinct from the Bookshelf Post model. */
18+
/** A post and its live gift links distinct from the Bookshelf Post model. */
1319
export interface Post {
1420
id: string;
1521
giftLinks: GiftLink[];
Lines changed: 44 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,50 @@
11
import {z} from 'zod';
2+
import errors from '@tryghost/errors';
23
import type {Knex} from 'knex';
3-
import {camelKeys, snakeKeys} from './case-keys';
4-
import {DbGiftLink} from './database';
5-
import {GiftLink} from './models';
6-
7-
// The columns the read path selects and the codec decodes into a GiftLink.
8-
export const GiftLinkRow = DbGiftLink.pick({
9-
token: true,
10-
created_at: true
11-
});
12-
13-
// Maps a selected row to/from the domain GiftLink (snake_case to camelCase, token branding).
14-
export const giftLinkCodec = z.codec(GiftLinkRow, GiftLink, {
15-
decode: row => camelKeys(row),
16-
encode: link => snakeKeys(link)
17-
});
18-
19-
const giftLinkColumns = Object.keys(GiftLinkRow.shape).map(column => `gift_links.${column}`);
20-
21-
// Executor-agnostic statements for the read shapes (joins, filters, columns): each is
22-
// parameterised by domain args and takes the connection at execution, so the service binds
23-
// knex (or a trx). The result generic names the row the codec expects, since knex can't infer
24-
// a dynamic column list.
25-
// Anchored on posts: zero rows means the post does not exist; a single all-null row means the
26-
// post exists with no live link (hence the nullable columns).
4+
import {GiftLinkRow, giftLinkCodec, giftLinkColumns} from './codec';
5+
import {type Post} from './models';
6+
7+
// The LEFT JOIN leaves every link column nullable; the explicit generic names a row shape knex
8+
// can't infer from a dynamic column list.
279
type LiveLinkRow = {[K in keyof z.input<typeof GiftLinkRow>]: z.input<typeof GiftLinkRow>[K] | null};
2810

29-
// LEFT JOINs from posts: a missing post yields zero rows, while a post with no live link yields one
30-
// row with null link columns. So zero rows means "no such post".
31-
export function liveLinksForPost(postId: string) {
32-
return (knex: Knex) => knex('posts')
33-
.where('posts.id', postId)
34-
.leftJoin('post_gift_links', 'post_gift_links.post_id', 'posts.id')
35-
.leftJoin('gift_links', 'gift_links.token', 'post_gift_links.gift_link_token')
36-
.select<LiveLinkRow[]>(giftLinkColumns);
37-
}
11+
export class GiftLinkQueries {
12+
private knex: Knex;
13+
14+
constructor({knex}: {knex: Knex}) {
15+
this.knex = knex;
16+
}
17+
18+
async getPost(postId: string): Promise<Post> {
19+
// Anchored on posts: zero rows means the post itself doesn't exist, not merely that it has
20+
// no live link.
21+
const rows = await this.knex('posts')
22+
.where('posts.id', postId)
23+
.leftJoin('post_gift_links', 'post_gift_links.post_id', 'posts.id')
24+
.leftJoin('gift_links', 'gift_links.token', 'post_gift_links.gift_link_token')
25+
.select<LiveLinkRow[]>(giftLinkColumns);
26+
27+
if (rows.length === 0) {
28+
throw new errors.NotFoundError({message: `Post ${postId} does not exist.`});
29+
}
30+
31+
const giftLinks = rows
32+
.filter((row): row is z.input<typeof GiftLinkRow> => row.token !== null)
33+
.map(row => z.decode(giftLinkCodec, row));
34+
return {id: postId, giftLinks};
35+
}
36+
37+
async getPostByToken(token: string): Promise<Post | null> {
38+
const row = await this.knex('post_gift_links')
39+
.join('gift_links', 'gift_links.token', 'post_gift_links.gift_link_token')
40+
.where('gift_links.token', token)
41+
.first<z.input<typeof GiftLinkRow> & {post_id: string}>(
42+
[...giftLinkColumns, 'post_gift_links.post_id as post_id']
43+
);
44+
return row ? {id: row.post_id, giftLinks: [z.decode(giftLinkCodec, row)]} : null;
45+
}
3846

39-
export function liveLinkForToken(token: string) {
40-
return (knex: Knex) => knex('post_gift_links')
41-
.join('gift_links', 'gift_links.token', 'post_gift_links.gift_link_token')
42-
.where('gift_links.token', token)
43-
.first<z.input<typeof GiftLinkRow> & {post_id: string}>([...giftLinkColumns, 'post_gift_links.post_id as post_id']);
47+
async isValidTokenForPost(token: string, postId: string): Promise<boolean> {
48+
return (await this.getPostByToken(token))?.id === postId;
49+
}
4450
}

0 commit comments

Comments
 (0)