|
1 | | -import crypto from 'crypto'; |
2 | | -import errors from '@tryghost/errors'; |
3 | | -import logging from '@tryghost/logging'; |
4 | 1 | import {z} from 'zod'; |
| 2 | +import errors from '@tryghost/errors'; |
5 | 3 | 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'; |
12 | 7 |
|
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}; |
36 | 11 |
|
37 | 12 | export class GiftLinksService { |
38 | 13 | private knex: Knex; |
39 | | - private Action: ActionRecorder; |
| 14 | + private recordAction: RecordGiftLinkAction; |
40 | 15 |
|
41 | | - constructor({knex, Action}: {knex: Knex; Action: ActionRecorder}) { |
| 16 | + constructor({knex, recordAction}: {knex: Knex; recordAction: RecordGiftLinkAction}) { |
42 | 17 | this.knex = knex; |
43 | | - this.Action = Action; |
| 18 | + this.recordAction = recordAction; |
44 | 19 | } |
45 | 20 |
|
46 | 21 | 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}; |
48 | 38 | } |
49 | 39 |
|
50 | 40 | 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; |
53 | 48 | } |
54 | 49 |
|
55 | 50 | async isValidTokenForPost(token: string, postId: string): Promise<boolean> { |
56 | 51 | return (await this.getPostByToken(token))?.id === postId; |
57 | 52 | } |
58 | 53 |
|
59 | 54 | async ensure(context: RequestContext, postId: string): Promise<Post> { |
60 | | - const post = await this.requirePost(postId); |
| 55 | + const post = await this.getPost(postId); |
61 | 56 | if (post.giftLinks.length) { |
62 | 57 | return post; |
63 | 58 | } |
64 | 59 | const minted = await this.mint(postId); |
65 | | - await this.recordAction(context, 'add', postId); |
| 60 | + await this.recordAction({context, verb: 'add', subject: postId}); |
66 | 61 | return minted; |
67 | 62 | } |
68 | 63 |
|
69 | 64 | async create(context: RequestContext, postId: string): Promise<Post> { |
70 | | - await this.requirePost(postId); |
| 65 | + await this.getPost(postId); // asserts the post exists (throws NotFound) |
71 | 66 | const minted = await this.mint(postId); |
72 | | - await this.recordAction(context, 'reset', postId); |
| 67 | + await this.recordAction({context, verb: 'reset', subject: postId}); |
73 | 68 | return minted; |
74 | 69 | } |
75 | 70 |
|
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. |
77 | 72 | async removeAll(context: RequestContext): Promise<number> { |
78 | 73 | const removed = await this.knex('post_gift_links').del(); |
79 | 74 | if (removed > 0) { |
80 | | - await this.recordAction(context, 'remove', null); |
| 75 | + await this.recordAction({context, verb: 'remove', subject: null}); |
81 | 76 | } |
82 | 77 | return removed; |
83 | 78 | } |
84 | 79 |
|
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 | | - |
96 | 80 | 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()}; |
99 | 82 | 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); |
105 | 85 | }); |
106 | 86 | return {id: postId, giftLinks: [link]}; |
107 | 87 | } |
108 | 88 |
|
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}); |
126 | 98 | } |
127 | 99 | } |
0 commit comments