feat(discussion): add discussion feature for game entities
Create entity_discussion_comments table and API endpoints Add discussion tabs to Pokemon, Item, Recipe, and Habitat detail views Support top-level comments, single-level replies, and deletion
This commit is contained in:
@@ -639,3 +639,34 @@ CREATE INDEX IF NOT EXISTS wiki_edit_logs_entity_idx
|
||||
|
||||
CREATE INDEX IF NOT EXISTS wiki_edit_logs_user_id_idx
|
||||
ON wiki_edit_logs(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS entity_discussion_comments (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
entity_type text NOT NULL CHECK (entity_type IN ('pokemon', 'items', 'recipes', 'habitats')),
|
||||
entity_id integer NOT NULL,
|
||||
parent_comment_id integer REFERENCES entity_discussion_comments(id) ON DELETE CASCADE,
|
||||
body text NOT NULL CHECK (length(body) BETWEEN 1 AND 1000),
|
||||
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
||||
deleted_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
||||
deleted_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE entity_discussion_comments DROP CONSTRAINT IF EXISTS entity_discussion_comments_entity_type_check;
|
||||
ALTER TABLE entity_discussion_comments ADD CONSTRAINT entity_discussion_comments_entity_type_check CHECK (
|
||||
entity_type IN ('pokemon', 'items', 'recipes', 'habitats')
|
||||
);
|
||||
|
||||
ALTER TABLE entity_discussion_comments ADD COLUMN IF NOT EXISTS deleted_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||
ALTER TABLE entity_discussion_comments ADD COLUMN IF NOT EXISTS deleted_at timestamptz;
|
||||
ALTER TABLE entity_discussion_comments ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
||||
|
||||
CREATE INDEX IF NOT EXISTS entity_discussion_comments_entity_idx
|
||||
ON entity_discussion_comments(entity_type, entity_id, created_at, id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS entity_discussion_comments_parent_idx
|
||||
ON entity_discussion_comments(parent_comment_id, created_at, id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS entity_discussion_comments_user_idx
|
||||
ON entity_discussion_comments(created_by_user_id);
|
||||
|
||||
@@ -116,6 +116,28 @@ type LifeCommentPayload = {
|
||||
body: string;
|
||||
};
|
||||
|
||||
type DiscussionEntityType = 'pokemon' | 'items' | 'recipes' | 'habitats';
|
||||
type DiscussionEntityDefinition = {
|
||||
table: string;
|
||||
};
|
||||
type EntityDiscussionCommentPayload = {
|
||||
body: string;
|
||||
};
|
||||
type EntityDiscussionCommentRow = {
|
||||
id: number;
|
||||
entityType: DiscussionEntityType;
|
||||
entityId: number;
|
||||
parentCommentId: number | null;
|
||||
body: string;
|
||||
deleted: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
author: { id: number; displayName: string } | null;
|
||||
};
|
||||
type EntityDiscussionComment = EntityDiscussionCommentRow & {
|
||||
replies: EntityDiscussionComment[];
|
||||
};
|
||||
|
||||
type LifeReactionType = 'like' | 'helpful' | 'fun' | 'thanks';
|
||||
type LifeReactionCounts = Record<LifeReactionType, number>;
|
||||
|
||||
@@ -263,6 +285,13 @@ const sortableContentDefinitions: Record<SortableContentType, SortableContentDef
|
||||
habitats: { table: 'habitats', entityType: 'habitats' }
|
||||
};
|
||||
|
||||
const discussionEntityDefinitions: Record<DiscussionEntityType, DiscussionEntityDefinition> = {
|
||||
pokemon: { table: 'pokemon' },
|
||||
items: { table: 'items' },
|
||||
recipes: { table: 'recipes' },
|
||||
habitats: { table: 'habitats' }
|
||||
};
|
||||
|
||||
function asString(value: QueryValue): string | undefined {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
@@ -1770,6 +1799,224 @@ export async function deleteLifeComment(id: number, userId: number) {
|
||||
return Boolean(result);
|
||||
}
|
||||
|
||||
function cleanDiscussionEntityType(value: unknown): DiscussionEntityType {
|
||||
if (typeof value !== 'string' || !Object.hasOwn(discussionEntityDefinitions, value)) {
|
||||
throw validationError('Entity type is invalid');
|
||||
}
|
||||
|
||||
return value as DiscussionEntityType;
|
||||
}
|
||||
|
||||
function cleanEntityDiscussionCommentPayload(payload: Record<string, unknown>): EntityDiscussionCommentPayload {
|
||||
const body = cleanName(payload.body, 'Please enter a comment');
|
||||
if (body.length > 1000) {
|
||||
throw validationError('Comment is too long');
|
||||
}
|
||||
|
||||
return { body };
|
||||
}
|
||||
|
||||
async function entityDiscussionExists(
|
||||
client: Pick<DbClient, 'query'>,
|
||||
entityType: DiscussionEntityType,
|
||||
entityId: number
|
||||
): Promise<boolean> {
|
||||
const definition = discussionEntityDefinitions[entityType];
|
||||
const result = await client.query<{ exists: boolean }>(
|
||||
`SELECT EXISTS (SELECT 1 FROM ${definition.table} WHERE id = $1) AS exists`,
|
||||
[entityId]
|
||||
);
|
||||
|
||||
return result.rows[0]?.exists === true;
|
||||
}
|
||||
|
||||
function entityDiscussionCommentProjection(whereClause: string): string {
|
||||
return `
|
||||
SELECT
|
||||
edc.id,
|
||||
edc.entity_type AS "entityType",
|
||||
edc.entity_id AS "entityId",
|
||||
edc.parent_comment_id AS "parentCommentId",
|
||||
CASE WHEN edc.deleted_at IS NULL THEN edc.body ELSE '' END AS body,
|
||||
edc.deleted_at IS NOT NULL AS deleted,
|
||||
edc.created_at AS "createdAt",
|
||||
edc.updated_at AS "updatedAt",
|
||||
CASE
|
||||
WHEN edc.deleted_at IS NOT NULL OR comment_user.id IS NULL THEN NULL
|
||||
ELSE json_build_object('id', comment_user.id, 'displayName', comment_user.display_name)
|
||||
END AS author
|
||||
FROM entity_discussion_comments edc
|
||||
LEFT JOIN users comment_user ON comment_user.id = edc.created_by_user_id
|
||||
${whereClause}
|
||||
`;
|
||||
}
|
||||
|
||||
function buildEntityDiscussionCommentTree(rows: EntityDiscussionCommentRow[]): EntityDiscussionComment[] {
|
||||
const comments = new Map<number, EntityDiscussionComment>();
|
||||
const topLevelComments: EntityDiscussionComment[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
comments.set(row.id, { ...row, replies: [] });
|
||||
}
|
||||
|
||||
for (const comment of comments.values()) {
|
||||
if (comment.parentCommentId === null) {
|
||||
topLevelComments.push(comment);
|
||||
continue;
|
||||
}
|
||||
|
||||
const parent = comments.get(comment.parentCommentId);
|
||||
if (parent?.parentCommentId === null) {
|
||||
parent.replies.push(comment);
|
||||
} else {
|
||||
topLevelComments.push(comment);
|
||||
}
|
||||
}
|
||||
|
||||
return topLevelComments;
|
||||
}
|
||||
|
||||
async function getEntityDiscussionCommentById(id: number): Promise<EntityDiscussionComment | null> {
|
||||
const row = await queryOne<EntityDiscussionCommentRow>(
|
||||
`
|
||||
${entityDiscussionCommentProjection('WHERE edc.id = $1')}
|
||||
`,
|
||||
[id]
|
||||
);
|
||||
|
||||
return row ? { ...row, replies: [] } : null;
|
||||
}
|
||||
|
||||
export async function listEntityDiscussionComments(
|
||||
entityTypeValue: string,
|
||||
entityIdValue: number
|
||||
): Promise<EntityDiscussionComment[] | null> {
|
||||
const entityType = cleanDiscussionEntityType(entityTypeValue);
|
||||
const entityId = requirePositiveInteger(entityIdValue, 'Record is invalid');
|
||||
|
||||
if (!(await entityDiscussionExists(pool, entityType, entityId))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rows = await query<EntityDiscussionCommentRow>(
|
||||
`
|
||||
${entityDiscussionCommentProjection('WHERE edc.entity_type = $1 AND edc.entity_id = $2')}
|
||||
ORDER BY edc.created_at, edc.id
|
||||
`,
|
||||
[entityType, entityId]
|
||||
);
|
||||
|
||||
return buildEntityDiscussionCommentTree(rows);
|
||||
}
|
||||
|
||||
export async function createEntityDiscussionComment(
|
||||
entityTypeValue: string,
|
||||
entityIdValue: number,
|
||||
payload: Record<string, unknown>,
|
||||
userId: number
|
||||
): Promise<EntityDiscussionComment | null> {
|
||||
const entityType = cleanDiscussionEntityType(entityTypeValue);
|
||||
const entityId = requirePositiveInteger(entityIdValue, 'Record is invalid');
|
||||
const cleanPayload = cleanEntityDiscussionCommentPayload(payload);
|
||||
|
||||
const id = await withTransaction(async (client) => {
|
||||
if (!(await entityDiscussionExists(client, entityType, entityId))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = await client.query<{ id: number }>(
|
||||
`
|
||||
INSERT INTO entity_discussion_comments (entity_type, entity_id, body, created_by_user_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id
|
||||
`,
|
||||
[entityType, entityId, cleanPayload.body, userId]
|
||||
);
|
||||
|
||||
return result.rows[0].id;
|
||||
});
|
||||
|
||||
return id ? getEntityDiscussionCommentById(id) : null;
|
||||
}
|
||||
|
||||
export async function createEntityDiscussionReply(
|
||||
entityTypeValue: string,
|
||||
entityIdValue: number,
|
||||
commentIdValue: number,
|
||||
payload: Record<string, unknown>,
|
||||
userId: number
|
||||
): Promise<EntityDiscussionComment | null> {
|
||||
const entityType = cleanDiscussionEntityType(entityTypeValue);
|
||||
const entityId = requirePositiveInteger(entityIdValue, 'Record is invalid');
|
||||
const commentId = requirePositiveInteger(commentIdValue, 'Comment is invalid');
|
||||
const cleanPayload = cleanEntityDiscussionCommentPayload(payload);
|
||||
|
||||
const id = await withTransaction(async (client) => {
|
||||
if (!(await entityDiscussionExists(client, entityType, entityId))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = await client.query<{ id: number }>(
|
||||
`
|
||||
INSERT INTO entity_discussion_comments (
|
||||
entity_type,
|
||||
entity_id,
|
||||
parent_comment_id,
|
||||
body,
|
||||
created_by_user_id
|
||||
)
|
||||
SELECT edc.entity_type, edc.entity_id, edc.id, $4, $5
|
||||
FROM entity_discussion_comments edc
|
||||
WHERE edc.entity_type = $1
|
||||
AND edc.entity_id = $2
|
||||
AND edc.id = $3
|
||||
AND edc.parent_comment_id IS NULL
|
||||
AND edc.deleted_at IS NULL
|
||||
RETURNING id
|
||||
`,
|
||||
[entityType, entityId, commentId, cleanPayload.body, userId]
|
||||
);
|
||||
|
||||
return result.rows[0]?.id ?? null;
|
||||
});
|
||||
|
||||
return id ? getEntityDiscussionCommentById(id) : null;
|
||||
}
|
||||
|
||||
export async function deleteEntityDiscussionComment(id: number, userId: number): Promise<boolean> {
|
||||
const commentId = requirePositiveInteger(id, 'Comment is invalid');
|
||||
const result = await queryOne<{ id: number }>(
|
||||
`
|
||||
UPDATE entity_discussion_comments
|
||||
SET deleted_at = now(),
|
||||
deleted_by_user_id = $2,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
AND created_by_user_id = $2
|
||||
AND deleted_at IS NULL
|
||||
RETURNING id
|
||||
`,
|
||||
[commentId, userId]
|
||||
);
|
||||
|
||||
return Boolean(result);
|
||||
}
|
||||
|
||||
async function deleteEntityDiscussionCommentsForEntity(
|
||||
client: DbClient,
|
||||
entityType: DiscussionEntityType,
|
||||
entityId: number
|
||||
): Promise<void> {
|
||||
await client.query(
|
||||
`
|
||||
DELETE FROM entity_discussion_comments
|
||||
WHERE entity_type = $1
|
||||
AND entity_id = $2
|
||||
`,
|
||||
[entityType, entityId]
|
||||
);
|
||||
}
|
||||
|
||||
export function isConfigType(type: string): type is ConfigType {
|
||||
return Object.hasOwn(configDefinitions, type);
|
||||
}
|
||||
@@ -2355,6 +2602,7 @@ export async function deletePokemon(id: number, userId: number) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await deleteEntityDiscussionCommentsForEntity(client, 'pokemon', id);
|
||||
await deleteEntityTranslations(client, 'pokemon', id);
|
||||
await recordEditLog(client, 'pokemon', id, 'delete', userId);
|
||||
return true;
|
||||
@@ -2561,6 +2809,7 @@ export async function deleteHabitat(id: number, userId: number) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await deleteEntityDiscussionCommentsForEntity(client, 'habitats', id);
|
||||
await deleteEntityTranslations(client, 'habitats', id);
|
||||
await recordEditLog(client, 'habitats', id, 'delete', userId);
|
||||
return true;
|
||||
@@ -2915,6 +3164,7 @@ export async function deleteItem(id: number, userId: number) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await deleteEntityDiscussionCommentsForEntity(client, 'items', id);
|
||||
await deleteEntityTranslations(client, 'items', id);
|
||||
await recordEditLog(client, 'items', id, 'delete', userId);
|
||||
return true;
|
||||
@@ -3082,6 +3332,7 @@ export async function deleteRecipe(id: number, userId: number) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await deleteEntityDiscussionCommentsForEntity(client, 'recipes', id);
|
||||
await recordEditLog(client, 'recipes', id, 'delete', userId);
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
cleanLocale,
|
||||
createConfig,
|
||||
createDailyChecklistItem,
|
||||
createEntityDiscussionComment,
|
||||
createEntityDiscussionReply,
|
||||
createHabitat,
|
||||
createItem,
|
||||
createLanguage,
|
||||
@@ -17,6 +19,7 @@ import {
|
||||
createRecipe,
|
||||
deleteConfig,
|
||||
deleteDailyChecklistItem,
|
||||
deleteEntityDiscussionComment,
|
||||
deleteHabitat,
|
||||
deleteItem,
|
||||
deleteLanguage,
|
||||
@@ -31,6 +34,7 @@ import {
|
||||
getPokemon,
|
||||
getRecipe,
|
||||
isConfigType,
|
||||
listEntityDiscussionComments,
|
||||
listConfig,
|
||||
listDailyChecklistItems,
|
||||
listHabitats,
|
||||
@@ -280,6 +284,60 @@ app.delete('/api/life-comments/:id', async (request, reply) => {
|
||||
return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
|
||||
});
|
||||
|
||||
app.get('/api/discussions/:entityType/:entityId/comments', async (request, reply) => {
|
||||
const { entityType, entityId } = request.params as { entityType: string; entityId: string };
|
||||
const comments = await listEntityDiscussionComments(entityType, Number(entityId));
|
||||
return comments ? comments : reply.code(404).send({ message: 'Not found' });
|
||||
});
|
||||
|
||||
app.post('/api/discussions/:entityType/:entityId/comments', async (request, reply) => {
|
||||
const user = await requireVerifiedUser(request, reply);
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { entityType, entityId } = request.params as { entityType: string; entityId: string };
|
||||
const comment = await createEntityDiscussionComment(
|
||||
entityType,
|
||||
Number(entityId),
|
||||
request.body as Record<string, unknown>,
|
||||
user.id
|
||||
);
|
||||
return comment ? reply.code(201).send(comment) : reply.code(404).send({ message: 'Not found' });
|
||||
});
|
||||
|
||||
app.post('/api/discussions/:entityType/:entityId/comments/:commentId/replies', async (request, reply) => {
|
||||
const user = await requireVerifiedUser(request, reply);
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { entityType, entityId, commentId } = request.params as {
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
commentId: string;
|
||||
};
|
||||
const comment = await createEntityDiscussionReply(
|
||||
entityType,
|
||||
Number(entityId),
|
||||
Number(commentId),
|
||||
request.body as Record<string, unknown>,
|
||||
user.id
|
||||
);
|
||||
return comment ? reply.code(201).send(comment) : reply.code(404).send({ message: 'Not found' });
|
||||
});
|
||||
|
||||
app.delete('/api/discussions/comments/:id', async (request, reply) => {
|
||||
const user = await requireVerifiedUser(request, reply);
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { id } = request.params as { id: string };
|
||||
const deleted = await deleteEntityDiscussionComment(Number(id), user.id);
|
||||
return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
|
||||
});
|
||||
|
||||
app.get('/api/pokemon', async (request) =>
|
||||
listPokemon(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user