Skip to content

Commit 280032d

Browse files
committed
Refactored Account to improve types & reduce boilerplate
ref https://linear.app/ghost/issue/AP-1082 The Account entity had grown complex due to its implementation with private members and public getters for privately mutable state. We were also encountering issues with nullable `id` fields that required awkward PersistedAccount type workarounds. - Account now always has a required `id` field - Introduced AccountDraft type for creating new accounts without IDs - Made AccountEntity immutable, returning new instances on save operations - Aligned `apFollowers` field to be nullable, matching database schema This refactoring simplifies type checking in the block method implementation, eliminating the need for ID checking and type casting when ensuring we're working with persisted accounts. No functional changes were introduced, though there is a slight type change with `apFollowers` now being nullable to match the database.
1 parent 3013b39 commit 280032d

27 files changed

+620
-715
lines changed

src/account/account.entity.ts

Lines changed: 128 additions & 161 deletions
Original file line numberDiff line numberDiff line change
@@ -1,161 +1,91 @@
11
import { randomUUID } from 'node:crypto';
2-
import { BaseEntity } from '../core/base.entity';
32
import { type CreatePostType, PostType } from '../post/post.entity';
4-
import type { Site } from '../site/site.service';
53

6-
export type PersistedAccount = Account & { id: number };
4+
export interface Account {
5+
readonly id: number;
6+
readonly uuid: string;
7+
readonly username: string;
8+
readonly name: string | null;
9+
readonly bio: string | null;
10+
readonly url: URL;
11+
readonly avatarUrl: URL | null;
12+
readonly bannerImageUrl: URL | null;
13+
readonly apId: URL;
14+
readonly apFollowers: URL | null;
15+
readonly isInternal: boolean;
16+
/**
17+
* Returns a new Account instance which needs to be saved.
18+
*/
19+
updateProfile(params: ProfileUpdateParams): Account;
20+
/**
21+
* @deprecated
22+
*/
23+
getApIdForPost(post: { type: CreatePostType; uuid: string }): URL;
24+
}
725

8-
export interface AccountData {
9-
id: number;
10-
uuid: string | null;
26+
export interface AccountDraft {
27+
uuid: string;
1128
username: string;
1229
name: string | null;
1330
bio: string | null;
31+
url: URL;
1432
avatarUrl: URL | null;
1533
bannerImageUrl: URL | null;
16-
site: Site | null;
17-
apId: URL | null;
18-
url: URL | null;
34+
apId: URL;
1935
apFollowers: URL | null;
36+
isInternal: boolean;
2037
}
2138

22-
export type AccountSite = {
23-
id: number;
24-
host: string;
25-
};
26-
27-
export interface ProfileUpdateParams {
28-
name?: string | null;
29-
bio?: string | null;
30-
username?: string;
31-
avatarUrl?: URL | null;
32-
bannerImageUrl?: URL | null;
33-
}
34-
35-
export class Account extends BaseEntity {
36-
public readonly uuid: string;
37-
public readonly url: URL;
38-
public readonly apId: URL;
39-
public readonly apFollowers: URL;
40-
41-
private _name: string | null;
42-
private _bio: string | null;
43-
private _username: string;
44-
private _avatarUrl: URL | null;
45-
private _bannerImageUrl: URL | null;
46-
39+
export class AccountEntity implements Account {
4740
constructor(
48-
public readonly id: number | null,
49-
uuid: string | null,
50-
username: string,
51-
name: string | null,
52-
bio: string | null,
53-
avatarUrl: URL | null,
54-
bannerImageUrl: URL | null,
55-
private readonly site: AccountSite | null,
56-
apId: URL | null,
57-
url: URL | null,
58-
apFollowers: URL | null,
59-
) {
60-
super(id);
61-
62-
this._name = name;
63-
this._bio = bio;
64-
this._username = username;
65-
this._avatarUrl = avatarUrl;
66-
this._bannerImageUrl = bannerImageUrl;
67-
68-
if (uuid === null) {
69-
this.uuid = randomUUID();
70-
} else {
71-
this.uuid = uuid;
72-
}
73-
if (apId === null) {
74-
this.apId = this.getApId();
75-
} else {
76-
this.apId = apId;
77-
}
78-
if (apFollowers === null) {
79-
this.apFollowers = this.getApFollowers();
80-
} else {
81-
this.apFollowers = apFollowers;
82-
}
83-
if (url === null) {
84-
this.url = this.apId;
85-
} else {
86-
this.url = url;
87-
}
88-
}
89-
90-
get name(): string | null {
91-
return this._name;
92-
}
93-
94-
get bio(): string | null {
95-
return this._bio;
96-
}
97-
98-
get username(): string {
99-
return this._username;
100-
}
101-
102-
get avatarUrl(): URL | null {
103-
return this._avatarUrl;
104-
}
105-
106-
get bannerImageUrl(): URL | null {
107-
return this._bannerImageUrl;
108-
}
109-
110-
updateProfile(params: ProfileUpdateParams): void {
111-
if (params.name !== undefined) {
112-
this._name = params.name;
113-
}
114-
115-
if (params.bio !== undefined) {
116-
this._bio = params.bio;
117-
}
118-
119-
if (params.username !== undefined) {
120-
this._username = params.username;
121-
}
122-
123-
if (params.avatarUrl !== undefined) {
124-
this._avatarUrl = params.avatarUrl;
125-
}
126-
127-
if (params.bannerImageUrl !== undefined) {
128-
this._bannerImageUrl = params.bannerImageUrl;
129-
}
130-
}
131-
132-
get isInternal() {
133-
return this.site !== null;
134-
}
135-
136-
getApId() {
137-
if (!this.isInternal) {
138-
throw new Error('Cannot get AP ID for External Accounts');
139-
}
140-
141-
return new URL(
142-
'.ghost/activitypub/users/index',
143-
`${Account.protocol}://${this.site!.host}`,
41+
public readonly id: number,
42+
public readonly uuid: string,
43+
public readonly username: string,
44+
public readonly name: string | null,
45+
public readonly bio: string | null,
46+
public readonly url: URL,
47+
public readonly avatarUrl: URL | null,
48+
public readonly bannerImageUrl: URL | null,
49+
public readonly apId: URL,
50+
public readonly apFollowers: URL | null,
51+
public readonly isInternal: boolean,
52+
) {}
53+
54+
static create(data: Data<Account>) {
55+
return new AccountEntity(
56+
data.id,
57+
data.uuid,
58+
data.username,
59+
data.name,
60+
data.bio,
61+
data.url,
62+
data.avatarUrl,
63+
data.bannerImageUrl,
64+
data.apId,
65+
data.apFollowers,
66+
data.isInternal,
14467
);
14568
}
14669

147-
getApFollowers() {
148-
if (!this.isInternal) {
149-
throw new Error('Cannot get AP Followers for External Accounts');
150-
}
151-
152-
return new URL(
153-
'.ghost/activitypub/followers/index',
154-
`${Account.protocol}://${this.site!.host}`,
155-
);
70+
static draft(from: AccountDraftData): AccountDraft {
71+
const uuid = randomUUID();
72+
const apId = !from.isInternal
73+
? from.apId
74+
: new URL('.ghost/activitypub/users/index', from.host);
75+
const apFollowers = !from.isInternal
76+
? from.apFollowers
77+
: new URL('.ghost/activitypub/followers/index', from.host);
78+
const url = from.url || apId;
79+
return {
80+
...from,
81+
uuid,
82+
url,
83+
apId,
84+
apFollowers,
85+
};
15686
}
15787

158-
getApIdForPost(post: { type: CreatePostType; uuid: string }) {
88+
getApIdForPost(post: { type: CreatePostType; uuid: string }): URL {
15989
if (!this.isInternal) {
16090
throw new Error('Cannot get AP ID for External Accounts');
16191
}
@@ -174,28 +104,65 @@ export class Account extends BaseEntity {
174104
}
175105
}
176106

177-
return new URL(
178-
`.ghost/activitypub/${type}/${post.uuid}`,
179-
`${Account.protocol}://${this.site!.host}`,
180-
);
107+
return new URL(`.ghost/activitypub/${type}/${post.uuid}`, this.apId);
181108
}
182109

183-
private static protocol: 'http' | 'https' =
184-
process.env.NODE_ENV === 'testing' ? 'http' : 'https';
185-
186-
static createFromData(data: AccountData) {
187-
return new Account(
188-
data.id,
189-
data.uuid,
190-
data.username,
191-
data.name,
192-
data.bio,
193-
data.avatarUrl,
194-
data.bannerImageUrl,
195-
data.site,
196-
data.apId,
197-
data.url,
198-
data.apFollowers,
199-
);
110+
updateProfile(params: ProfileUpdateParams) {
111+
type P = ProfileUpdateParams;
112+
const get = <K extends keyof P>(prop: K): P[K] =>
113+
params[prop] === undefined ? this[prop] : params[prop];
114+
115+
return AccountEntity.create({
116+
...this,
117+
username: get('username'),
118+
name: get('name'),
119+
bio: get('bio'),
120+
avatarUrl: get('avatarUrl'),
121+
bannerImageUrl: get('bannerImageUrl'),
122+
});
200123
}
201124
}
125+
126+
type ProfileUpdateParams = {
127+
name?: string | null;
128+
bio?: string | null;
129+
username?: string;
130+
avatarUrl?: URL | null;
131+
bannerImageUrl?: URL | null;
132+
};
133+
134+
/**
135+
* Internal accounts require a `host` so we can calculate the ActivityPub URLs
136+
*/
137+
type InternalAccountDraftData = {
138+
isInternal: true;
139+
host: URL;
140+
username: string;
141+
name: string;
142+
bio: string | null;
143+
url: URL | null;
144+
avatarUrl: URL | null;
145+
bannerImageUrl: URL | null;
146+
};
147+
148+
/**
149+
* External accounts require the ActivityPub URLs to be passed in
150+
*/
151+
type ExternalAccountDraftData = {
152+
isInternal: false;
153+
username: string;
154+
name: string;
155+
bio: string | null;
156+
url: URL | null;
157+
avatarUrl: URL | null;
158+
bannerImageUrl: URL | null;
159+
apId: URL;
160+
apFollowers: URL | null;
161+
};
162+
163+
type AccountDraftData = InternalAccountDraftData | ExternalAccountDraftData;
164+
165+
type Data<T> = {
166+
// biome-ignore lint/suspicious/noExplicitAny: These are not publicly usable instances of any
167+
[K in keyof T as T[K] extends (...args: any[]) => any ? never : K]: T[K];
168+
};

0 commit comments

Comments
 (0)