fix(p8-003): enforce club membership checks on 7 community endpoints
Enforce requireClubMembership on social.getPost, addComment, comments, like, unlike, challenges.leave, and challenges.submitProgress so private club content is not readable/actionable by non-members (was IDOR). Extract the membership helpers (requireClubMembership, resolveClubIdFromPost, resolveClubIdFromChallenge) into a shared dependency-free module (nessa-community-authz.ts) so all membership-gated endpoints use one implementation and the libsql connection surface is typed uniformly. Each post/challenge endpoint now resolves the owning clubId first (NOT_FOUND if the resource is missing) then gates on it. Add regression tests (nessa-community-authz.test.ts) covering: non-member FORBIDDEN vs member allowed for all 7 endpoints' resolve→require sequences, NOT_FOUND for missing post/challenge, and a join→allowed→leave→blocked integration.
This commit is contained in:
203
src/server/api/routers/nessa-community-authz.test.ts
Normal file
203
src/server/api/routers/nessa-community-authz.test.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import { describe, it, expect, beforeAll, beforeEach } from "vitest";
|
||||||
|
import { Database } from "bun:sqlite";
|
||||||
|
import {
|
||||||
|
requireClubMembership,
|
||||||
|
resolveClubIdFromPost,
|
||||||
|
resolveClubIdFromChallenge,
|
||||||
|
type NessaConn
|
||||||
|
} from "./nessa-community-authz";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression tests for p8-003: private club content (posts, comments, likes,
|
||||||
|
* challenge participation) must NOT be readable/actionable by non-members.
|
||||||
|
*
|
||||||
|
* These tests exercise the shared membership-gating helpers directly against
|
||||||
|
* an in-memory SQLite DB (`bun:sqlite`) wrapped to match the libsql
|
||||||
|
* `execute({ sql, args }) -> { rows }` contract the router uses. The
|
||||||
|
* `nessa-community.ts` router calls these same helpers in the same order, so a
|
||||||
|
* pass here guarantees the authorization decision each endpoint makes before
|
||||||
|
* touching data.
|
||||||
|
*
|
||||||
|
* Two users are seeded: A is a member (owner) of club C (and owns the post +
|
||||||
|
* challenge under test); B is NOT a member of C.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// In-memory SQLite connection (libsql-shaped)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let db: Database;
|
||||||
|
let conn: NessaConn;
|
||||||
|
|
||||||
|
function makeConn(): NessaConn {
|
||||||
|
return {
|
||||||
|
execute: async ({
|
||||||
|
sql,
|
||||||
|
args
|
||||||
|
}: {
|
||||||
|
sql: string;
|
||||||
|
args?: (string | number | null)[];
|
||||||
|
}) => {
|
||||||
|
const stmt = db.prepare(sql);
|
||||||
|
const upper = sql.trim().toUpperCase();
|
||||||
|
const isRead = upper.startsWith("SELECT") || upper.startsWith("WITH");
|
||||||
|
if (isRead) {
|
||||||
|
const rows = stmt.all(...(args ?? []));
|
||||||
|
return { rows: rows as unknown[] };
|
||||||
|
}
|
||||||
|
stmt.run(...(args ?? []));
|
||||||
|
return { rows: [] as unknown[] };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Schema + seed
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const USER_A = "user-a";
|
||||||
|
const USER_B = "user-b";
|
||||||
|
const CLUB_C = "club-c";
|
||||||
|
const POST_P = "post-p"; // created by A in club C
|
||||||
|
const CHALLENGE_CH = "challenge-ch"; // in club C, created by A
|
||||||
|
|
||||||
|
function initSchema() {
|
||||||
|
db = new Database(":memory:");
|
||||||
|
db.run("PRAGMA foreign_keys = ON");
|
||||||
|
|
||||||
|
db.run("CREATE TABLE clubMemberships (id TEXT PRIMARY KEY, clubId TEXT, userId TEXT, role TEXT, joinedAt TEXT)");
|
||||||
|
db.run("CREATE TABLE clubPosts (id TEXT PRIMARY KEY, clubId TEXT, userId TEXT, content TEXT, postType TEXT, challengeId TEXT, createdAt TEXT, updatedAt TEXT)");
|
||||||
|
db.run("CREATE TABLE clubChallenges (id TEXT PRIMARY KEY, clubId TEXT, title TEXT, description TEXT, goalType TEXT, goalValue REAL, startDate TEXT, endDate TEXT, createdBy TEXT, status TEXT, createdAt TEXT, updatedAt TEXT)");
|
||||||
|
}
|
||||||
|
|
||||||
|
function seed() {
|
||||||
|
// Club C: A is a member (owner). B is NOT.
|
||||||
|
db.run(
|
||||||
|
"INSERT INTO clubMemberships (id, clubId, userId, role, joinedAt) VALUES (?, ?, ?, ?, datetime('now'))",
|
||||||
|
["mem-a", CLUB_C, USER_A, "owner"]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Post P by A in club C.
|
||||||
|
db.run(
|
||||||
|
"INSERT INTO clubPosts (id, clubId, userId, content, postType, challengeId, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, NULL, datetime('now'), datetime('now'))",
|
||||||
|
[POST_P, CLUB_C, USER_A, "Hello from A", "text"]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Challenge CH in club C, created by A.
|
||||||
|
db.run(
|
||||||
|
"INSERT INTO clubChallenges (id, clubId, title, description, goalType, goalValue, startDate, endDate, createdBy, status, createdAt, updatedAt) VALUES (?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))",
|
||||||
|
[CHALLENGE_CH, CLUB_C, "Run 5k", "distance", 5000, "2025-01-01", "2025-12-31", USER_A, "active"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
initSchema();
|
||||||
|
seed();
|
||||||
|
conn = makeConn();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Keep membership state stable across tests (join/leave integration mutates it).
|
||||||
|
db.run("DELETE FROM clubMemberships");
|
||||||
|
db.run(
|
||||||
|
"INSERT INTO clubMemberships (id, clubId, userId, role, joinedAt) VALUES (?, ?, ?, ?, datetime('now'))",
|
||||||
|
["mem-a", CLUB_C, USER_A, "owner"]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function errCode(p: Promise<unknown>): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
await p;
|
||||||
|
return undefined;
|
||||||
|
} catch (e) {
|
||||||
|
return (e as { code?: string }).code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("p8-003: resolveClubIdFromPost", () => {
|
||||||
|
it("resolves the owning club for an existing post", async () => {
|
||||||
|
expect(await resolveClubIdFromPost(conn, POST_P)).toBe(CLUB_C);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws NOT_FOUND for a missing post", async () => {
|
||||||
|
expect(await errCode(resolveClubIdFromPost(conn, "no-such-post"))).toBe("NOT_FOUND");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("p8-003: resolveClubIdFromChallenge", () => {
|
||||||
|
it("resolves the owning club for an existing challenge", async () => {
|
||||||
|
expect(await resolveClubIdFromChallenge(conn, CHALLENGE_CH)).toBe(CLUB_C);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws NOT_FOUND for a missing challenge", async () => {
|
||||||
|
expect(await errCode(resolveClubIdFromChallenge(conn, "no-such-challenge"))).toBe("NOT_FOUND");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("p8-003: requireClubMembership", () => {
|
||||||
|
it("passes silently for a member", async () => {
|
||||||
|
await expect(requireClubMembership(conn, CLUB_C, USER_A)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws FORBIDDEN for a non-member", async () => {
|
||||||
|
expect(await errCode(requireClubMembership(conn, CLUB_C, USER_B))).toBe("FORBIDDEN");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End-to-end authorization sequence for each of the 7 fixed endpoints. The
|
||||||
|
* router does exactly: resolve the resource's clubId, then
|
||||||
|
* requireClubMembership on it. Replaying that here proves the decision a
|
||||||
|
* non-member is rejected / a member is allowed.
|
||||||
|
*/
|
||||||
|
describe("p8-003: endpoint authorization sequences (resolve → require)", () => {
|
||||||
|
// social.getPost / addComment / comments / like / unlike
|
||||||
|
it("getPost/addComment/comments/like/unlike: non-member B rejected with FORBIDDEN", async () => {
|
||||||
|
const clubId = await resolveClubIdFromPost(conn, POST_P);
|
||||||
|
expect(await errCode(requireClubMembership(conn, clubId, USER_B))).toBe("FORBIDDEN");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getPost/addComment/comments/like/unlike: member A allowed", async () => {
|
||||||
|
const clubId = await resolveClubIdFromPost(conn, POST_P);
|
||||||
|
await expect(requireClubMembership(conn, clubId, USER_A)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// challenges.leave / challenges.submitProgress
|
||||||
|
it("challenges.leave / submitProgress: non-member B rejected with FORBIDDEN", async () => {
|
||||||
|
const clubId = await resolveClubIdFromChallenge(conn, CHALLENGE_CH);
|
||||||
|
expect(await errCode(requireClubMembership(conn, clubId, USER_B))).toBe("FORBIDDEN");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("challenges.leave / submitProgress: member A allowed", async () => {
|
||||||
|
const clubId = await resolveClubIdFromChallenge(conn, CHALLENGE_CH);
|
||||||
|
await expect(requireClubMembership(conn, clubId, USER_A)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("p8-003: join then allowed / leave then blocked (integration)", () => {
|
||||||
|
it("B is blocked, allowed after joining C, blocked again after leaving", async () => {
|
||||||
|
// Initially blocked.
|
||||||
|
const clubId = await resolveClubIdFromPost(conn, POST_P);
|
||||||
|
expect(await errCode(requireClubMembership(conn, clubId, USER_B))).toBe("FORBIDDEN");
|
||||||
|
|
||||||
|
// B joins.
|
||||||
|
db.run(
|
||||||
|
"INSERT INTO clubMemberships (id, clubId, userId, role, joinedAt) VALUES (?, ?, ?, ?, datetime('now'))",
|
||||||
|
["mem-b", CLUB_C, USER_B, "member"]
|
||||||
|
);
|
||||||
|
await expect(requireClubMembership(conn, clubId, USER_B)).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
// B leaves.
|
||||||
|
db.run("DELETE FROM clubMemberships WHERE clubId = ? AND userId = ?", [
|
||||||
|
CLUB_C,
|
||||||
|
USER_B
|
||||||
|
]);
|
||||||
|
expect(await errCode(requireClubMembership(conn, clubId, USER_B))).toBe("FORBIDDEN");
|
||||||
|
});
|
||||||
|
});
|
||||||
90
src/server/api/routers/nessa-community-authz.ts
Normal file
90
src/server/api/routers/nessa-community-authz.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Community authorization helpers (p8-003).
|
||||||
|
*
|
||||||
|
* Membership gating for `nessaCommunityRouter`. Extracted into a dependency-
|
||||||
|
* free module (no `~/env/server` import) so it can be unit-tested directly
|
||||||
|
* against an in-memory SQLite connection without booting the SSR-guarded env
|
||||||
|
* chain, and so every membership-gated endpoint shares ONE implementation of
|
||||||
|
* each check (no ad-hoc duplicated SQL).
|
||||||
|
*
|
||||||
|
* Contract mirrors the libsql client the router uses: a connection exposes
|
||||||
|
* `execute({ sql, args }) -> { rows }`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Minimal libsql-shaped connection surface used by community authz. */
|
||||||
|
export interface NessaConn {
|
||||||
|
execute: (q: {
|
||||||
|
sql: string;
|
||||||
|
args?: (string | number | null)[];
|
||||||
|
}) => Promise<{ rows: unknown[] }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Require that the calling user is a member of the club (or is the owner).
|
||||||
|
*
|
||||||
|
* Throws `TRPCError({ code: "FORBIDDEN", message: "Not a member of this club" })`
|
||||||
|
* on miss and returns void on success. This is the single source of truth for
|
||||||
|
* "is this user allowed to touch this club's content" — every membership-gated
|
||||||
|
* endpoint in `nessa-community.ts` MUST route through this helper.
|
||||||
|
*/
|
||||||
|
export async function requireClubMembership(
|
||||||
|
conn: NessaConn,
|
||||||
|
clubId: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const result = await conn.execute({
|
||||||
|
sql: "SELECT id FROM clubMemberships WHERE clubId = ? AND userId = ?",
|
||||||
|
args: [clubId, userId]
|
||||||
|
});
|
||||||
|
if (!result.rows.length) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "Not a member of this club"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the clubId that owns a post. Returns the owning club's id, or
|
||||||
|
* throws `NOT_FOUND` if the post does not exist. Used by read/interaction
|
||||||
|
* endpoints (`getPost`, `addComment`, `comments`, `like`, `unlike`) to derive
|
||||||
|
* the club a target post belongs to before gating on membership.
|
||||||
|
*/
|
||||||
|
export async function resolveClubIdFromPost(
|
||||||
|
conn: NessaConn,
|
||||||
|
postId: string
|
||||||
|
): Promise<string> {
|
||||||
|
const result = await conn.execute({
|
||||||
|
sql: "SELECT clubId FROM clubPosts WHERE id = ?",
|
||||||
|
args: [postId]
|
||||||
|
});
|
||||||
|
if (!result.rows.length) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: "Post not found" });
|
||||||
|
}
|
||||||
|
return (result.rows[0] as unknown as { clubId: string }).clubId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the clubId that owns a challenge. Returns the owning club's id, or
|
||||||
|
* throws `NOT_FOUND` if the challenge does not exist. Used by challenge
|
||||||
|
* interaction endpoints (`challenges.leave`, `challenges.submitProgress`) to
|
||||||
|
* derive the club a challenge belongs to before gating on membership.
|
||||||
|
*/
|
||||||
|
export async function resolveClubIdFromChallenge(
|
||||||
|
conn: NessaConn,
|
||||||
|
challengeId: string
|
||||||
|
): Promise<string> {
|
||||||
|
const result = await conn.execute({
|
||||||
|
sql: "SELECT clubId FROM clubChallenges WHERE id = ?",
|
||||||
|
args: [challengeId]
|
||||||
|
});
|
||||||
|
if (!result.rows.length) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Challenge not found"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return (result.rows[0] as unknown as { clubId: string }).clubId;
|
||||||
|
}
|
||||||
@@ -2,6 +2,11 @@ import { createTRPCRouter, nessaProcedure } from "../utils";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { NessaConnectionFactory } from "~/server/database";
|
import { NessaConnectionFactory } from "~/server/database";
|
||||||
|
import {
|
||||||
|
requireClubMembership,
|
||||||
|
resolveClubIdFromPost,
|
||||||
|
resolveClubIdFromChallenge
|
||||||
|
} from "./nessa-community-authz";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* nessa.community.* — Community features (clubs, challenges, social feed).
|
* nessa.community.* — Community features (clubs, challenges, social feed).
|
||||||
@@ -230,23 +235,9 @@ interface CommentRow {
|
|||||||
authorAvatarUrl: string | null;
|
authorAvatarUrl: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Require that the calling user is a member of the club (or is the owner). */
|
// Membership gating helpers (`requireClubMembership`, `resolveClubIdFromPost`,
|
||||||
async function requireClubMembership(
|
// `resolveClubIdFromChallenge`) live in `./nessa-community-authz` and are
|
||||||
conn: ReturnType<typeof NessaConnectionFactory>,
|
// shared by every membership-gated endpoint below — see p8-003.
|
||||||
clubId: string,
|
|
||||||
userId: string
|
|
||||||
): Promise<void> {
|
|
||||||
const result = await conn.execute({
|
|
||||||
sql: "SELECT id FROM clubMemberships WHERE clubId = ? AND userId = ?",
|
|
||||||
args: [clubId, userId]
|
|
||||||
});
|
|
||||||
if (!result.rows.length) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "FORBIDDEN",
|
|
||||||
message: "Not a member of this club"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Router
|
// Router
|
||||||
@@ -947,6 +938,8 @@ export const nessaCommunityRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
try {
|
try {
|
||||||
const conn = NessaConnectionFactory();
|
const conn = NessaConnectionFactory();
|
||||||
|
const clubId = await resolveClubIdFromChallenge(conn, input.id);
|
||||||
|
await requireClubMembership(conn, clubId, ctx.nessaUserId);
|
||||||
await conn.execute({
|
await conn.execute({
|
||||||
sql: "DELETE FROM clubChallengeParticipations WHERE challengeId = ? AND userId = ?",
|
sql: "DELETE FROM clubChallengeParticipations WHERE challengeId = ? AND userId = ?",
|
||||||
args: [input.id, ctx.nessaUserId]
|
args: [input.id, ctx.nessaUserId]
|
||||||
@@ -967,6 +960,11 @@ export const nessaCommunityRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
try {
|
try {
|
||||||
const conn = NessaConnectionFactory();
|
const conn = NessaConnectionFactory();
|
||||||
|
const clubId = await resolveClubIdFromChallenge(
|
||||||
|
conn,
|
||||||
|
input.challengeId
|
||||||
|
);
|
||||||
|
await requireClubMembership(conn, clubId, ctx.nessaUserId);
|
||||||
|
|
||||||
// Upsert participation: create if absent, update progress.
|
// Upsert participation: create if absent, update progress.
|
||||||
const existing = await conn.execute({
|
const existing = await conn.execute({
|
||||||
@@ -1102,6 +1100,8 @@ export const nessaCommunityRouter = createTRPCRouter({
|
|||||||
.query(async ({ input, ctx }) => {
|
.query(async ({ input, ctx }) => {
|
||||||
try {
|
try {
|
||||||
const conn = NessaConnectionFactory();
|
const conn = NessaConnectionFactory();
|
||||||
|
const clubId = await resolveClubIdFromPost(conn, input.id);
|
||||||
|
await requireClubMembership(conn, clubId, ctx.nessaUserId);
|
||||||
const result = await conn.execute({
|
const result = await conn.execute({
|
||||||
sql: `SELECT p.id, p.clubId, p.userId, p.content, p.postType, p.challengeId,
|
sql: `SELECT p.id, p.clubId, p.userId, p.content, p.postType, p.challengeId,
|
||||||
p.createdAt, p.updatedAt,
|
p.createdAt, p.updatedAt,
|
||||||
@@ -1166,6 +1166,8 @@ export const nessaCommunityRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
try {
|
try {
|
||||||
const conn = NessaConnectionFactory();
|
const conn = NessaConnectionFactory();
|
||||||
|
const clubId = await resolveClubIdFromPost(conn, input.postId);
|
||||||
|
await requireClubMembership(conn, clubId, ctx.nessaUserId);
|
||||||
const existing = await conn.execute({
|
const existing = await conn.execute({
|
||||||
sql: "SELECT id FROM clubPostLikes WHERE postId = ? AND userId = ?",
|
sql: "SELECT id FROM clubPostLikes WHERE postId = ? AND userId = ?",
|
||||||
args: [input.postId, ctx.nessaUserId]
|
args: [input.postId, ctx.nessaUserId]
|
||||||
@@ -1193,6 +1195,8 @@ export const nessaCommunityRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
try {
|
try {
|
||||||
const conn = NessaConnectionFactory();
|
const conn = NessaConnectionFactory();
|
||||||
|
const clubId = await resolveClubIdFromPost(conn, input.postId);
|
||||||
|
await requireClubMembership(conn, clubId, ctx.nessaUserId);
|
||||||
await conn.execute({
|
await conn.execute({
|
||||||
sql: "DELETE FROM clubPostLikes WHERE postId = ? AND userId = ?",
|
sql: "DELETE FROM clubPostLikes WHERE postId = ? AND userId = ?",
|
||||||
args: [input.postId, ctx.nessaUserId]
|
args: [input.postId, ctx.nessaUserId]
|
||||||
@@ -1213,6 +1217,8 @@ export const nessaCommunityRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
try {
|
try {
|
||||||
const conn = NessaConnectionFactory();
|
const conn = NessaConnectionFactory();
|
||||||
|
const clubId = await resolveClubIdFromPost(conn, input.postId);
|
||||||
|
await requireClubMembership(conn, clubId, ctx.nessaUserId);
|
||||||
const commentId = crypto.randomUUID();
|
const commentId = crypto.randomUUID();
|
||||||
await conn.execute({
|
await conn.execute({
|
||||||
sql: `INSERT INTO clubPostComments (id, postId, userId, content)
|
sql: `INSERT INTO clubPostComments (id, postId, userId, content)
|
||||||
@@ -1232,9 +1238,11 @@ export const nessaCommunityRouter = createTRPCRouter({
|
|||||||
|
|
||||||
comments: nessaProcedure
|
comments: nessaProcedure
|
||||||
.input(postLikeSchema)
|
.input(postLikeSchema)
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input, ctx }) => {
|
||||||
try {
|
try {
|
||||||
const conn = NessaConnectionFactory();
|
const conn = NessaConnectionFactory();
|
||||||
|
const clubId = await resolveClubIdFromPost(conn, input.postId);
|
||||||
|
await requireClubMembership(conn, clubId, ctx.nessaUserId);
|
||||||
const result = await conn.execute({
|
const result = await conn.execute({
|
||||||
sql: `SELECT c.id, c.postId, c.userId, c.content, c.createdAt, c.updatedAt,
|
sql: `SELECT c.id, c.postId, c.userId, c.content, c.createdAt, c.updatedAt,
|
||||||
u.displayName AS authorDisplayName, u.avatarUrl AS authorAvatarUrl
|
u.displayName AS authorDisplayName, u.avatarUrl AS authorAvatarUrl
|
||||||
|
|||||||
Reference in New Issue
Block a user