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
2 changes: 1 addition & 1 deletion ghost/core/server/services/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ For example:
implementation through `init()` and `service`. It qualifies without owning a
background worker.
- Staff invitation acceptance is a domain action owned by `invites`. Its
`accept.js` function keeps transaction state local to each invocation; it
`accept.ts` function keeps transaction state local to each invocation; it
does not need its own service root or lifecycle.
- Magic-link token and URL helpers are reusable support for several workflows,
not a separately boot-managed capability. They belong in their own library,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,40 @@
const errors = require('@tryghost/errors');
const tpl = require('@tryghost/tpl');
const security = require('@tryghost/security');
import type { Knex } from 'knex';
import * as errors from '@tryghost/errors';
import tpl from '@tryghost/tpl';
import * as security from '@tryghost/security';

export type Invitation = {
invitation: Array<{ token: string; name: string; password: string }>;
};

type TransactionOptions<Transaction> = {
context: { internal: true };
transacting: Transaction;
forUpdate: true;
};

export type AcceptanceModels<Transaction = Knex.Transaction> = {
Base: {
transaction<T>(callback: (transacting: Transaction) => Promise<T>): Promise<T>;
};
Invite: {
findOne(
data: { token: string; status: 'sent' },
options: TransactionOptions<Transaction>,
): Promise<{
get(key: 'expires'): number;
get(key: 'email' | 'role_id'): string;
destroy(options: TransactionOptions<Transaction>): Promise<unknown>;
} | null>;
};
User: {
findOne(data: { email: string }, options: TransactionOptions<Transaction>): Promise<unknown>;
add(
data: { email: string; name: string; password: string; roles: string[] },
options: TransactionOptions<Transaction>,
): Promise<unknown>;
};
};

const messages = {
inviteNotFound: 'Invite not found.',
Expand All @@ -12,12 +46,19 @@ const messages = {
},
};

async function accept(invitation, models) {
export async function accept<Transaction>(
invitation: Invitation,
models: AcceptanceModels<Transaction>,
): Promise<void> {
const data = invitation.invitation[0];
const inviteToken = security.url.decodeBase64(data.token);

return models.Base.transaction(async (transacting) => {
const options = { context: { internal: true }, transacting, forUpdate: true };
const options: TransactionOptions<Transaction> = {
context: { internal: true },
transacting,
forUpdate: true,
};

const invite = await models.Invite.findOne({ token: inviteToken, status: 'sent' }, options);

Expand Down Expand Up @@ -55,5 +96,3 @@ async function accept(invitation, models) {
);
});
}

module.exports = accept;
3 changes: 1 addition & 2 deletions ghost/core/server/services/invites/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { defineService } from '../../../kernel/define-service';
// @ts-expect-error The existing CommonJS implementation has no type declarations.
import Invites from './invites';
import { Invites } from './invites';

export default defineService('Invites', () => {
const settingsCache = require('../../../shared/settings-cache');
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,73 @@
const security = require('@tryghost/security');
const tpl = require('@tryghost/tpl');
const accept = require('./accept');
import * as security from '@tryghost/security';
import tpl from '@tryghost/tpl';
import { accept, type AcceptanceModels, type Invitation } from './accept';

type Invite = {
id: string;
get(key: 'token' | 'email'): string;
};

type InviteData = { email: string; role_id: string };
type InviteOptions = Record<string, unknown>;
type InviteEmailData = {
blogName: string;
invitedByName?: string;
invitedByEmail?: string;
resetLink: string;
recipientEmail: string;
};

type Dependencies = {
settingsCache: { get(key: 'title'): string };
settingsHelpers: { getDefaultEmailDomain(): string };
mailService: {
utils: {
generateContent(options: {
data: InviteEmailData;
template: 'invite-user' | 'invite-user-by-api-key';
}): Promise<{ html: string; text: string }>;
};
};
urlUtils: {
urlFor(context: 'admin', absolute: true): string;
urlJoin(...parts: string[]): string;
};
models: AcceptanceModels;
logging: { warn(message: string): void };
};

type AddInviteRequest = {
api: {
mail: {
send(
payload: {
mail: Array<{
message: {
to: string;
replyTo?: string;
subject: string;
html: string;
text: string;
};
options: Record<string, never>;
}>;
},
options: { context: { internal: true } },
): Promise<unknown>;
};
};
InviteModel: {
findOne(
data: { email: string },
options: InviteOptions,
): Promise<{ destroy(options: InviteOptions): Promise<unknown> } | null>;
add(data: InviteData, options: InviteOptions): Promise<Invite>;
edit(data: { status: 'sent' }, options: InviteOptions & { id: string }): Promise<Invite>;
};
invites: InviteData[];
options: InviteOptions;
user: { name?: string; email?: string };
};

const messages = {
invitedByName: '{invitedByName} has invited you to join {blogName}',
Expand All @@ -11,8 +78,22 @@ const messages = {
},
};

class Invites {
constructor({ settingsCache, settingsHelpers, mailService, urlUtils, models, logging }) {
export class Invites {
private readonly settingsCache: Dependencies['settingsCache'];
private readonly settingsHelpers: Dependencies['settingsHelpers'];
private readonly mailService: Dependencies['mailService'];
private readonly urlUtils: Dependencies['urlUtils'];
private readonly models: Dependencies['models'];
private readonly logging: Dependencies['logging'];

constructor({
settingsCache,
settingsHelpers,
mailService,
urlUtils,
models,
logging,
}: Dependencies) {
this.settingsCache = settingsCache;
this.settingsHelpers = settingsHelpers;
this.mailService = mailService;
Expand All @@ -21,13 +102,13 @@ class Invites {
this.logging = logging;
}

accept(invitation) {
accept(invitation: Invitation) {
return accept(invitation, this.models);
}

add({ api, InviteModel, invites, options, user }) {
let invite;
let emailData;
add({ api, InviteModel, invites, options, user }: AddInviteRequest): Promise<Invite> {
let invite: Invite;
let emailData: InviteEmailData;

return InviteModel.findOne({ email: invites[0].email }, options)
.then((existingInvite) => {
Expand Down Expand Up @@ -118,19 +199,24 @@ class Invites {
.then((editedInvite) => {
return editedInvite;
})
.catch((err) => {
if (err && err.errorType === 'EmailError') {
.catch((err: unknown) => {
if (
err &&
typeof err === 'object' &&
'errorType' in err &&
err.errorType === 'EmailError' &&
'message' in err
) {
const errorMessage = tpl(messages.errorSendingEmail.error, {
message: err.message,
});
const helpText = tpl(messages.errorSendingEmail.help);
err.message = `${errorMessage} ${helpText}`;
this.logging.warn(err.message);
const message = `${errorMessage} ${helpText}`;
err.message = message;
this.logging.warn(message);
}

return Promise.reject(err);
});
}
}

module.exports = Invites;
Loading
Loading