Skip to content

Commit ae7e092

Browse files
committed
Extracted gift-links side effects into ports
Kept a single GiftLinksService but pulled the action-log write out into actions.ts (injected as a port rather than reaching for the model), moved the DB row<->domain codec into codec.ts, renamed database.ts to schema.ts, and moved token generation onto the model. Replaced the DB-backed service-level integration suite with unit tests plus HTTP-level e2e coverage for the not-found and concurrency cases.
1 parent 76d941c commit ae7e092

12 files changed

Lines changed: 187 additions & 336 deletions

File tree

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: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import {GiftLinksService} from './service';
2+
import {recordGiftLinkAction, type RecordGiftLinkAction} from './actions';
23

3-
export type {RequestContext} from './service';
4+
export type {RequestContext} from './actions';
45

5-
// Set by init() at boot, not at import: knex only exists once the DB has connected.
6+
// Constructed by init() at boot, not at import: knex is only available once the DB has connected.
67
export let service: GiftLinksService | undefined;
78

89
export function init(): void {
@@ -13,5 +14,7 @@ export function init(): void {
1314
const {knex} = require('../../data/db');
1415
const models = require('../../models');
1516

16-
service = new GiftLinksService({knex, Action: models.Action});
17+
const recordAction: RecordGiftLinkAction = ({context, verb, subject}) =>
18+
recordGiftLinkAction({Action: models.Action, context, verb, subject});
19+
service = new GiftLinksService({knex, recordAction});
1720
}

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[];

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

Lines changed: 0 additions & 44 deletions
This file was deleted.

ghost/core/core/server/services/gift-links/database.ts renamed to ghost/core/core/server/services/gift-links/schema.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,11 @@ import {z} from 'zod';
22
import type {Knex} from 'knex';
33

44
// MySQL returns a Date, SQLite a string/number; normalise to Date on read.
5-
// Lives here with the row schema that uses it until a second table needs it.
65
const DbDate = z.codec(z.union([z.date(), z.string(), z.number()]), z.date(), {
76
decode: value => new Date(value),
87
encode: date => date
98
});
109

11-
// The gift_links table row: the single source for the read projection (queries.ts) and the knex
12-
// types below.
1310
export const DbGiftLink = z.object({
1411
token: z.string(),
1512
post_id: z.string(),
@@ -25,7 +22,6 @@ interface DbPostGiftLink {
2522
updated_at: Date | null;
2623
}
2724

28-
// knex table types, derived from the schemas above so each row shape has a single source.
2925
declare module 'knex/types/tables' {
3026
interface Tables {
3127
gift_links: Knex.CompositeTableType<

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import {z} from 'zod';
22
import {snakeKeys} from './case-keys';
33
import {GiftLink} from './models';
44

5-
// Response schemas — the shapes the admin endpoints emit.
65
const GiftLinkResource = z.object({
76
token: z.string(),
87
created_at: z.date()
Lines changed: 51 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,127 +1,99 @@
1-
import crypto from 'crypto';
2-
import errors from '@tryghost/errors';
3-
import logging from '@tryghost/logging';
41
import {z} from 'zod';
2+
import errors from '@tryghost/errors';
53
import type {Knex} from 'knex';
6-
import {GiftLinkToken, type GiftLink, type Post} from './models';
7-
import * as queries from './queries';
8-
9-
export function generateGiftLinkToken(): GiftLinkToken {
10-
return GiftLinkToken.parse(crypto.randomBytes(24).toString('base64url'));
11-
}
4+
import {GiftLinkRow, giftLinkCodec, giftLinkColumns} from './codec';
5+
import {generateGiftLinkToken, type GiftLink, type Post} from './models';
6+
import {type RecordGiftLinkAction, type RequestContext} from './actions';
127

13-
export interface Actor {
14-
id: string;
15-
type: 'user' | 'integration';
16-
}
17-
18-
interface ActionRecorder {
19-
add(data: Record<string, unknown>, options: {autoRefresh: boolean}): Promise<unknown>;
20-
}
21-
22-
export interface RequestContext {
23-
actor: Actor | null;
24-
}
25-
26-
// The history UI only surfaces a verb-specific label (action_name) for 'edited' events; 'added' and
27-
// 'deleted' render as the bare event. So 'reset' maps to 'edited' to read as "reset", while
28-
// 'add'/'remove' read as plain "added"/"deleted".
29-
const COMMANDS = {
30-
add: 'added',
31-
reset: 'edited',
32-
remove: 'deleted'
33-
} as const satisfies Record<string, 'added' | 'edited' | 'deleted'>;
34-
35-
type GiftLinkVerb = keyof typeof COMMANDS;
8+
// The LEFT JOIN leaves every link column nullable; the explicit generic names a row shape knex
9+
// can't infer from a dynamic column list.
10+
type LiveLinkRow = {[K in keyof z.input<typeof GiftLinkRow>]: z.input<typeof GiftLinkRow>[K] | null};
3611

3712
export class GiftLinksService {
3813
private knex: Knex;
39-
private Action: ActionRecorder;
14+
private recordAction: RecordGiftLinkAction;
4015

41-
constructor({knex, Action}: {knex: Knex; Action: ActionRecorder}) {
16+
constructor({knex, recordAction}: {knex: Knex; recordAction: RecordGiftLinkAction}) {
4217
this.knex = knex;
43-
this.Action = Action;
18+
this.recordAction = recordAction;
4419
}
4520

4621
async getPost(postId: string): Promise<Post> {
47-
return this.requirePost(postId);
22+
// Anchored on posts: zero rows means the post itself doesn't exist, not merely that it has
23+
// no live link.
24+
const rows = await this.knex('posts')
25+
.where('posts.id', postId)
26+
.leftJoin('post_gift_links', 'post_gift_links.post_id', 'posts.id')
27+
.leftJoin('gift_links', 'gift_links.token', 'post_gift_links.gift_link_token')
28+
.select<LiveLinkRow[]>(giftLinkColumns);
29+
30+
if (rows.length === 0) {
31+
throw new errors.NotFoundError({message: `Post ${postId} does not exist.`});
32+
}
33+
34+
const giftLinks = rows
35+
.filter((row): row is z.input<typeof GiftLinkRow> => row.token !== null)
36+
.map(row => z.decode(giftLinkCodec, row));
37+
return {id: postId, giftLinks};
4838
}
4939

5040
async getPostByToken(token: string): Promise<Post | null> {
51-
const row = await queries.liveLinkForToken(token)(this.knex);
52-
return row ? {id: row.post_id, giftLinks: [z.decode(queries.giftLinkCodec, row)]} : null;
41+
const row = await this.knex('post_gift_links')
42+
.join('gift_links', 'gift_links.token', 'post_gift_links.gift_link_token')
43+
.where('gift_links.token', token)
44+
.first<z.input<typeof GiftLinkRow> & {post_id: string}>(
45+
[...giftLinkColumns, 'post_gift_links.post_id as post_id']
46+
);
47+
return row ? {id: row.post_id, giftLinks: [z.decode(giftLinkCodec, row)]} : null;
5348
}
5449

5550
async isValidTokenForPost(token: string, postId: string): Promise<boolean> {
5651
return (await this.getPostByToken(token))?.id === postId;
5752
}
5853

5954
async ensure(context: RequestContext, postId: string): Promise<Post> {
60-
const post = await this.requirePost(postId);
55+
const post = await this.getPost(postId);
6156
if (post.giftLinks.length) {
6257
return post;
6358
}
6459
const minted = await this.mint(postId);
65-
await this.recordAction(context, 'add', postId);
60+
await this.recordAction({context, verb: 'add', subject: postId});
6661
return minted;
6762
}
6863

6964
async create(context: RequestContext, postId: string): Promise<Post> {
70-
await this.requirePost(postId);
65+
await this.getPost(postId); // asserts the post exists (throws NotFound)
7166
const minted = await this.mint(postId);
72-
await this.recordAction(context, 'reset', postId);
67+
await this.recordAction({context, verb: 'reset', subject: postId});
7368
return minted;
7469
}
7570

76-
// Remove every live association; the gift_links rows stay as history.
71+
// gift_links rows are kept as history; only the live association is removed.
7772
async removeAll(context: RequestContext): Promise<number> {
7873
const removed = await this.knex('post_gift_links').del();
7974
if (removed > 0) {
80-
await this.recordAction(context, 'remove', null);
75+
await this.recordAction({context, verb: 'remove', subject: null});
8176
}
8277
return removed;
8378
}
8479

85-
private async requirePost(postId: string): Promise<Post> {
86-
const rows = await queries.liveLinksForPost(postId)(this.knex);
87-
if (rows.length === 0) {
88-
throw new errors.NotFoundError({message: `Post ${postId} does not exist.`});
89-
}
90-
const giftLinks = rows
91-
.filter((row): row is z.input<typeof queries.GiftLinkRow> => row.token !== null)
92-
.map(row => z.decode(queries.giftLinkCodec, row));
93-
return {id: postId, giftLinks};
94-
}
95-
9680
private async mint(postId: string): Promise<Post> {
97-
const now = new Date();
98-
const link: GiftLink = {token: generateGiftLinkToken(), createdAt: now};
81+
const link: GiftLink = {token: generateGiftLinkToken(), createdAt: new Date()};
9982
await this.knex.transaction(async (trx) => {
100-
await trx('gift_links').insert({...z.encode(queries.giftLinkCodec, link), post_id: postId});
101-
await trx('post_gift_links')
102-
.insert({post_id: postId, gift_link_token: link.token, created_at: now})
103-
.onConflict('post_id')
104-
.merge({gift_link_token: link.token, updated_at: now});
83+
await this.addToHistory(trx, postId, link);
84+
await this.setLiveLink(trx, postId, link);
10585
});
10686
return {id: postId, giftLinks: [link]};
10787
}
10888

109-
private async recordAction(context: RequestContext, verb: GiftLinkVerb, subject: string | null): Promise<void> {
110-
if (!context.actor) {
111-
return;
112-
}
113-
const event = COMMANDS[verb];
114-
try {
115-
await this.Action.add({
116-
event,
117-
resource_type: 'gift_link',
118-
resource_id: subject,
119-
actor_type: context.actor.type,
120-
actor_id: context.actor.id,
121-
...(event === 'edited' ? {context: {action_name: verb}} : {})
122-
}, {autoRefresh: false});
123-
} catch (err) {
124-
logging.error(err);
125-
}
89+
private addToHistory(trx: Knex.Transaction, postId: string, link: GiftLink) {
90+
return trx('gift_links').insert({...z.encode(giftLinkCodec, link), post_id: postId});
91+
}
92+
93+
private setLiveLink(trx: Knex.Transaction, postId: string, link: GiftLink) {
94+
return trx('post_gift_links')
95+
.insert({post_id: postId, gift_link_token: link.token, created_at: link.createdAt})
96+
.onConflict('post_id')
97+
.merge({gift_link_token: link.token, updated_at: link.createdAt});
12698
}
12799
}

ghost/core/test/e2e-api/admin/gift-links.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,26 @@ describe('Gift Links Admin API', function () {
116116
});
117117
});
118118

119+
describe('a post that does not exist', function () {
120+
const MISSING_POST_ID = '0123456789abcdef01234567';
121+
122+
it('404s on GET, PUT and POST', async function () {
123+
await agent.get(`posts/${MISSING_POST_ID}/gift_links/`).expectStatus(404);
124+
await agent.put(`posts/${MISSING_POST_ID}/gift_links/`).expectStatus(404);
125+
await agent.post(`posts/${MISSING_POST_ID}/gift_links/`).expectStatus(404);
126+
});
127+
});
128+
129+
it('concurrent ensures settle on a single live link (last writer wins)', async function () {
130+
await Promise.all([
131+
agent.put(`posts/${postId}/gift_links/`).expectStatus(200),
132+
agent.put(`posts/${postId}/gift_links/`).expectStatus(200)
133+
]);
134+
135+
const {body} = await agent.get(`posts/${postId}/gift_links/`).expectStatus(200);
136+
assert.equal(body.gift_links.length, 1);
137+
});
138+
119139
describe('records actions in the history (via the actions API)', function () {
120140
let actorId: string;
121141

0 commit comments

Comments
 (0)