Skip to content

Commit

Permalink
Fix #105 (custom error handling with Fastify)
Browse files Browse the repository at this point in the history
  • Loading branch information
PurkkaKoodari committed Sep 17, 2023
1 parent fd90b2f commit 029cf2a
Show file tree
Hide file tree
Showing 5 changed files with 155 additions and 163 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ import type {

export abstract class CustomError extends Error {
public readonly statusCode: number;
public readonly error: string;
public readonly code: string;

protected constructor(statusCode: number, name: string, message: string) {
protected constructor(statusCode: number, code: string, message: string) {
super(message);
this.statusCode = statusCode;
this.error = name;
this.code = code;
}
}

Expand Down
290 changes: 141 additions & 149 deletions packages/ilmomasiina-backend/src/routes/admin/events/updateEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,168 +12,160 @@ import { Quota } from '../../../models/quota';
import { eventDetailsForAdmin } from '../../events/getEventDetails';
import { refreshSignupPositions } from '../../signups/computeSignupPosition';
import { toDate } from '../../utils';
import { EditConflict, WouldMoveSignupsToQueue } from './errors';
import { EditConflict } from './errors';

export default async function updateEvent(
request: FastifyRequest<{ Params: AdminEventPathParams, Body: EventUpdateBody }>,
response: FastifyReply,
): Promise<AdminEventResponse | EditConflictError | WouldMoveSignupsToQueueError> {
try {
await Event.sequelize!.transaction(async (transaction) => {
// Get the event with all relevant information for the update
const event = await Event.findByPk(request.params.id, {
attributes: ['id', 'openQuotaSize', 'draft', 'updatedAt'],
transaction,
lock: Transaction.LOCK.UPDATE,
});
if (event === null) {
throw new NotFound('No event found with id');
}
// Postgres doesn't support FOR UPDATE with LEFT JOIN
event.quotas = await Quota.findAll({
where: { eventId: event.id },
attributes: ['id'],
transaction,
lock: Transaction.LOCK.UPDATE,
});
event.questions = await Question.findAll({
where: { eventId: event.id },
attributes: ['id'],
await Event.sequelize!.transaction(async (transaction) => {
// Get the event with all relevant information for the update
const event = await Event.findByPk(request.params.id, {
attributes: ['id', 'openQuotaSize', 'draft', 'updatedAt'],
transaction,
lock: Transaction.LOCK.UPDATE,
});
if (event === null) {
throw new NotFound('No event found with id');
}
// Postgres doesn't support FOR UPDATE with LEFT JOIN
event.quotas = await Quota.findAll({
where: { eventId: event.id },
attributes: ['id'],
transaction,
lock: Transaction.LOCK.UPDATE,
});
event.questions = await Question.findAll({
where: { eventId: event.id },
attributes: ['id'],
transaction,
lock: Transaction.LOCK.UPDATE,
});

// Find existing questions and quotas for requested IDs
const updatedQuestions = request.body.questions?.map((question) => ({
...question,
existing: question.id ? event.questions!.find((old) => question.id === old.id) : undefined,
}));
const updatedQuotas = request.body.quotas?.map((quota) => ({
...quota,
existing: quota.id ? event.quotas!.find((old) => quota.id === old.id) : undefined,
}));

// Find questions and quotas that were requested by ID but don't exist
const deletedQuestions = updatedQuestions
?.filter((question) => !question.existing && question.id)
.map((question) => question.id as Question['id'])
?? [];
const deletedQuotas = updatedQuotas
?.filter((quota) => !quota.existing && quota.id)
.map((quota) => quota.id as Quota['id'])
?? [];

// Check for edit conflicts
const expectedUpdatedAt = new Date(request.body.updatedAt ?? '');
if (
event.updatedAt.getTime() !== expectedUpdatedAt.getTime()
|| deletedQuestions.length
|| deletedQuotas.length
) {
throw new EditConflict(event.updatedAt, deletedQuotas, deletedQuestions);
}

// Update the Event
const wasPublic = !event.draft;
await event.update({
...request.body,
registrationEndDate: toDate(request.body.registrationEndDate),
registrationStartDate: toDate(request.body.registrationStartDate),
date: toDate(request.body.date),
endDate: toDate(request.body.endDate),
}, { transaction });

if (updatedQuestions !== undefined) {
const reuseQuestionIds = updatedQuestions
.map((question) => question.id)
.filter((questionId) => questionId) as Question['id'][];

// Remove previous Questions not present in request
await Question.destroy({
where: {
eventId: event.id,
id: {
[Op.notIn]: reuseQuestionIds,
},
},
transaction,
lock: Transaction.LOCK.UPDATE,
});

// Find existing questions and quotas for requested IDs
const updatedQuestions = request.body.questions?.map((question) => ({
...question,
existing: question.id ? event.questions!.find((old) => question.id === old.id) : undefined,
}));
const updatedQuotas = request.body.quotas?.map((quota) => ({
...quota,
existing: quota.id ? event.quotas!.find((old) => quota.id === old.id) : undefined,
// Update or create the new Questions
await Promise.all(updatedQuestions.map(async (question, order) => {
const questionAttribs = {
...question,
order,
};
// Update if an id was provided
if (question.existing) {
await question.existing.update({
...questionAttribs,
// TODO: Splitting by semicolon might cause problems - requires better solution
options: questionAttribs.options ? questionAttribs.options.join(';') : null,
}, { transaction });
} else {
await Question.create({
...questionAttribs,
// TODO: Splitting by semicolon might cause problems - requires better solution
options: questionAttribs.options ? questionAttribs.options.join(';') : null,
eventId: event.id,
}, { transaction });
}
}));
}

// Find questions and quotas that were requested by ID but don't exist
const deletedQuestions = updatedQuestions
?.filter((question) => !question.existing && question.id)
.map((question) => question.id as Question['id'])
?? [];
const deletedQuotas = updatedQuotas
?.filter((quota) => !quota.existing && quota.id)
.map((quota) => quota.id as Quota['id'])
?? [];

// Check for edit conflicts
const expectedUpdatedAt = new Date(request.body.updatedAt ?? '');
if (
event.updatedAt.getTime() !== expectedUpdatedAt.getTime()
|| deletedQuestions.length
|| deletedQuotas.length
) {
throw new EditConflict(event.updatedAt, deletedQuotas, deletedQuestions);
}

// Update the Event
const wasPublic = !event.draft;
await event.update({
...request.body,
registrationEndDate: toDate(request.body.registrationEndDate),
registrationStartDate: toDate(request.body.registrationStartDate),
date: toDate(request.body.date),
endDate: toDate(request.body.endDate),
}, { transaction });

if (updatedQuestions !== undefined) {
const reuseQuestionIds = updatedQuestions
.map((question) => question.id)
.filter((questionId) => questionId) as Question['id'][];

// Remove previous Questions not present in request
await Question.destroy({
where: {
eventId: event.id,
id: {
[Op.notIn]: reuseQuestionIds,
},
if (updatedQuotas !== undefined) {
const reuseQuotaIds = updatedQuotas
.map((quota) => quota.id)
.filter((quotaId) => quotaId) as Quota['id'][];

// Remove previous Quotas not present in request
await Quota.destroy({
where: {
eventId: event.id,
id: {
[Op.notIn]: reuseQuotaIds,
},
transaction,
});

// Update or create the new Questions
await Promise.all(updatedQuestions.map(async (question, order) => {
const questionAttribs = {
...question,
order,
};
// Update if an id was provided
if (question.existing) {
await question.existing.update({
...questionAttribs,
// TODO: Splitting by semicolon might cause problems - requires better solution
options: questionAttribs.options ? questionAttribs.options.join(';') : null,
}, { transaction });
} else {
await Question.create({
...questionAttribs,
// TODO: Splitting by semicolon might cause problems - requires better solution
options: questionAttribs.options ? questionAttribs.options.join(';') : null,
eventId: event.id,
}, { transaction });
}
}));
}

if (updatedQuotas !== undefined) {
const reuseQuotaIds = updatedQuotas
.map((quota) => quota.id)
.filter((quotaId) => quotaId) as Quota['id'][];

// Remove previous Quotas not present in request
await Quota.destroy({
where: {
},
transaction,
});

// Update or create the new Quotas
await Promise.all(updatedQuotas.map(async (quota, order) => {
const quotaAttribs = {
...quota,
order,
};
// Update if an id was provided
if (quota.existing) {
await quota.existing.update(quotaAttribs, { transaction });
} else {
await Quota.create({
...quotaAttribs,
eventId: event.id,
id: {
[Op.notIn]: reuseQuotaIds,
},
},
transaction,
});

// Update or create the new Quotas
await Promise.all(updatedQuotas.map(async (quota, order) => {
const quotaAttribs = {
...quota,
order,
};
// Update if an id was provided
if (quota.existing) {
await quota.existing.update(quotaAttribs, { transaction });
} else {
await Quota.create({
...quotaAttribs,
eventId: event.id,
}, { transaction });
}
}));
}

// Refresh positions, but don't move signups to queue unless explicitly allowed
await refreshSignupPositions(event, transaction, request.body.moveSignupsToQueue);

const isPublic = !event.draft;
let action: AuditEvent;
if (isPublic === wasPublic) action = AuditEvent.EDIT_EVENT;
else action = isPublic ? AuditEvent.PUBLISH_EVENT : AuditEvent.UNPUBLISH_EVENT;

await request.logEvent(action, { event, transaction });
});
} catch (e) {
if (e instanceof EditConflict || e instanceof WouldMoveSignupsToQueue) {
response.status(e.statusCode);
return e;
}, { transaction });
}
}));
}
throw e;
}

// Refresh positions, but don't move signups to queue unless explicitly allowed
await refreshSignupPositions(event, transaction, request.body.moveSignupsToQueue);

const isPublic = !event.draft;
let action: AuditEvent;
if (isPublic === wasPublic) action = AuditEvent.EDIT_EVENT;
else action = isPublic ? AuditEvent.PUBLISH_EVENT : AuditEvent.UNPUBLISH_EVENT;

await request.logEvent(action, { event, transaction });
});

const updatedEvent = await eventDetailsForAdmin(request.params.id);

Expand Down
12 changes: 6 additions & 6 deletions packages/ilmomasiina-components/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,15 @@ export interface FetchOptions {

export class ApiError extends Error {
status: number;
className?: string;
data?: any;
code?: string;
response?: any;

constructor(status: number, data: any) {
super(data.message);
constructor(status: number, response: any) {
super(response.message);
this.status = status;
this.name = 'ApiError';
this.className = data.className;
this.data = data.data;
this.code = response.code;
this.response = response;
}

static async fromResponse(response: Response) {
Expand Down
8 changes: 4 additions & 4 deletions packages/ilmomasiina-frontend/src/modules/editor/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,12 +274,12 @@ export const publishEventUpdate = (
dispatch(loaded(response));
return response;
} catch (e) {
if (e instanceof ApiError && e.className === 'would-move-signups-to-queue') {
dispatch(moveToQueueWarning(e.data!.count));
if (e instanceof ApiError && e.code === 'WouldMoveSignupsToQueue') {
dispatch(moveToQueueWarning(e.response!.count));
return null;
}
if (e instanceof ApiError && e.className === 'edit-conflict') {
dispatch(editConflictDetected(e.data!));
if (e instanceof ApiError && e.code === 'EditConflict') {
dispatch(editConflictDetected(e.response!));
return null;
}
dispatch(saveFailed());
Expand Down
2 changes: 1 addition & 1 deletion packages/ilmomasiina-models/src/schema/errors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { quotaID } from '../quota/attributes';
/** Response schema for a generic error. */
export const errorResponse = Type.Object({
statusCode: Type.Number(),
error: Type.String(),
code: Type.String(),
message: Type.String(),
});

Expand Down

0 comments on commit 029cf2a

Please sign in to comment.