diff --git a/README.md b/README.md index af98dc2..a11d89d 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ Log out and back in, and an **Admin** link appears in the header. - [x] Email verification and password reset (Resend) - [x] Paid posts (pay to unlock, Lemon Squeezy checkout, creator earnings ledger) - [x] Sign in with Google; public Explore page with optional AdSense ads +- [x] Communities (create, join, post inside them; owners can remove posts) ## License diff --git a/apps/api/src/controllers/communityController.ts b/apps/api/src/controllers/communityController.ts new file mode 100644 index 0000000..073f57a --- /dev/null +++ b/apps/api/src/controllers/communityController.ts @@ -0,0 +1,105 @@ +import type { Request, Response } from "express"; +import { communitySlugSchema } from "@commune/shared"; +import type { CommunityDTO } from "@commune/shared"; +import { Community } from "../models/Community.js"; +import { Membership } from "../models/Membership.js"; +import { badRequest, conflict, forbidden, notFound } from "../utils/errors.js"; +import { escapeRegex } from "../utils/regex.js"; +import { toCommunityDTO } from "../utils/serializers.js"; +import { sendPostPage } from "./postController.js"; + +const MAX_COMMUNITIES_PER_USER = 5; +const LIST_LIMIT = 30; + +async function findBySlug(raw: string | undefined) { + const parsed = communitySlugSchema.safeParse(raw); + if (!parsed.success) throw notFound("Community not found"); + const community = await Community.findOne({ slug: parsed.data }).lean(); + if (!community) throw notFound("Community not found"); + return community; +} + +const isDuplicate = (err: unknown) => (err as { code?: number }).code === 11000; + +export async function create(req: Request, res: Response) { + const { slug, name, description } = req.body as { slug: string; name: string; description: string }; + + // A cap keeps one account from claiming every good name. + const owned = await Community.countDocuments({ creator: req.userId }); + if (owned >= MAX_COMMUNITIES_PER_USER) { + throw forbidden(`You can create up to ${MAX_COMMUNITIES_PER_USER} communities`); + } + + let community; + try { + community = await Community.create({ + slug, + name, + description, + creator: req.userId, + memberCount: 1, + }); + } catch (err) { + if (isDuplicate(err)) throw conflict("That community address is already taken"); + throw err; + } + await Membership.create({ user: req.userId, community: community._id, role: "owner" }); + res.status(201).json(toCommunityDTO(community, "owner")); +} + +/** Most popular communities, optionally filtered by text, or only the ones I belong to. */ +export async function list(req: Request, res: Response) { + const { q, mine } = (res.locals as { query: { q?: string; mine?: "1" } }).query; + + const memberships = await Membership.find({ user: req.userId }).select("community role").lean(); + const roles = new Map(memberships.map((m) => [m.community.toString(), m.role])); + + const filter: Record = {}; + if (mine) filter._id = { $in: memberships.map((m) => m.community) }; + if (q) { + const pattern = { $regex: escapeRegex(q), $options: "i" }; + filter.$or = [{ slug: pattern }, { name: pattern }]; + } + + const rows = await Community.find(filter).sort({ memberCount: -1, _id: -1 }).limit(LIST_LIMIT).lean(); + const items: CommunityDTO[] = rows.map((c) => + toCommunityDTO(c, (roles.get(c._id.toString()) as "owner" | "member" | undefined) ?? null), + ); + res.json(items); +} + +export async function get(req: Request, res: Response) { + const community = await findBySlug(req.params.slug); + const membership = await Membership.findOne({ user: req.userId, community: community._id }) + .select("role") + .lean(); + res.json(toCommunityDTO(community, membership?.role ?? null)); +} + +export async function join(req: Request, res: Response) { + const community = await findBySlug(req.params.slug); + try { + await Membership.create({ user: req.userId, community: community._id, role: "member" }); + await Community.updateOne({ _id: community._id }, { $inc: { memberCount: 1 } }); + } catch (err) { + // Already a member (a double click): treat as success. + if (!isDuplicate(err)) throw err; + } + res.status(204).end(); +} + +export async function leave(req: Request, res: Response) { + const community = await findBySlug(req.params.slug); + const existing = await Membership.findOne({ user: req.userId, community: community._id }) + .select("role") + .lean(); + if (existing?.role === "owner") throw badRequest("Owners can't leave their own community"); + const removed = await Membership.findOneAndDelete({ user: req.userId, community: community._id }); + if (removed) await Community.updateOne({ _id: community._id }, { $inc: { memberCount: -1 } }); + res.status(204).end(); +} + +export async function posts(req: Request, res: Response) { + const community = await findBySlug(req.params.slug); + await sendPostPage(req, res, { community: community._id }); +} diff --git a/apps/api/src/controllers/postController.ts b/apps/api/src/controllers/postController.ts index cb7f17e..227c1cd 100644 --- a/apps/api/src/controllers/postController.ts +++ b/apps/api/src/controllers/postController.ts @@ -3,7 +3,9 @@ import type { Request, Response } from "express"; import type { Page, PostDTO } from "@commune/shared"; import { Comment } from "../models/Comment.js"; import { Follow } from "../models/Follow.js"; +import { Community } from "../models/Community.js"; import { Like } from "../models/Like.js"; +import { Membership } from "../models/Membership.js"; import { Post } from "../models/Post.js"; import { notify } from "../services/notificationService.js"; import { unlockedPostIds } from "../services/purchaseService.js"; @@ -17,6 +19,7 @@ import { } from "../utils/serializers.js"; const AUTHOR_FIELDS = "username avatarUrl"; +const COMMUNITY_FIELDS = "slug name"; const param = (req: Request, name: string): string => { const value = req.params[name]; @@ -34,6 +37,7 @@ async function likedSet(userId: string, postIds: mongoose.Types.ObjectId[]) { async function loadPost(id: string): Promise { const post = (await Post.findById(id) .populate("author", AUTHOR_FIELDS) + .populate("community", COMMUNITY_FIELDS) .lean()) as unknown as PopulatedPost | null; if (!post) throw notFound("Post not found"); return post; @@ -78,6 +82,7 @@ export async function sendPostPage( .sort({ _id: -1 }) .limit(limit + 1) .populate("author", AUTHOR_FIELDS) + .populate("community", COMMUNITY_FIELDS) .lean()) as unknown as PopulatedPost[]; const hasMore = rows.length > limit; @@ -117,8 +122,19 @@ export async function getPost(req: Request, res: Response) { } export async function createPost(req: Request, res: Response) { + let communityId: string | null = null; + if (req.body.community) { + const community = await Community.findOne({ slug: req.body.community }).select("_id").lean(); + if (!community) throw notFound("Community not found"); + // Only members can post in a community. + if (!(await Membership.exists({ user: req.userId, community: community._id }))) { + throw forbidden("Join this community to post in it"); + } + communityId = community._id.toString(); + } const created = await Post.create({ author: req.userId, + community: communityId, content: req.body.content, imageUrl: req.body.imageUrl ?? null, priceCents: req.body.priceCents ?? null, @@ -131,12 +147,16 @@ export async function createPost(req: Request, res: Response) { export async function deletePost(req: Request, res: Response) { const id = param(req, "id"); - const post = await Post.findById(id).select("author").lean(); + const post = await Post.findById(id).select("author community").lean(); if (!post) throw notFound("Post not found"); - // Authors can delete their own posts; admins can remove anyone's. - if (post.author.toString() !== req.userId && req.userRole !== "admin") { - throw forbidden("You can only delete your own posts"); - } + // Authors can delete their own posts; admins can remove anyone's; so can the owner of the + // community the post was made in. + const allowed = + post.author.toString() === req.userId || + req.userRole === "admin" || + (post.community != null && + Boolean(await Membership.exists({ user: req.userId, community: post.community, role: "owner" }))); + if (!allowed) throw forbidden("You can only delete your own posts"); await removePost(id, req.userId); res.status(204).end(); diff --git a/apps/api/src/controllers/publicController.ts b/apps/api/src/controllers/publicController.ts index cfef59b..de59419 100644 --- a/apps/api/src/controllers/publicController.ts +++ b/apps/api/src/controllers/publicController.ts @@ -18,6 +18,7 @@ export async function listPosts(_req: Request, res: Response) { .sort({ _id: -1 }) .limit(limit + 1) .populate("author", "username avatarUrl") + .populate("community", "slug name") .lean()) as unknown as PopulatedPost[]; const hasMore = rows.length > limit; diff --git a/apps/api/src/controllers/searchController.ts b/apps/api/src/controllers/searchController.ts index 17e9fb5..030e9a8 100644 --- a/apps/api/src/controllers/searchController.ts +++ b/apps/api/src/controllers/searchController.ts @@ -57,6 +57,7 @@ export async function posts(req: Request, res: Response) { .skip((page - 1) * SEARCH_PAGE_SIZE) .limit(SEARCH_PAGE_SIZE + 1) .populate("author", "username avatarUrl") + .populate("community", "slug name") .lean()) as unknown as PopulatedPost[]; const { items, nextCursor } = paginate(rows, page); diff --git a/apps/api/src/middleware/rateLimit.ts b/apps/api/src/middleware/rateLimit.ts index 3f9a0a6..39d8c92 100644 --- a/apps/api/src/middleware/rateLimit.ts +++ b/apps/api/src/middleware/rateLimit.ts @@ -76,3 +76,12 @@ export const publicLimiter = rateLimit({ skip, message: { error: "Too many requests, slow down." }, }); + +export const communityLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, + limit: 10, + standardHeaders: "draft-7", + legacyHeaders: false, + skip, + message: { error: "You're creating communities too fast, try again later." }, +}); diff --git a/apps/api/src/models/Community.ts b/apps/api/src/models/Community.ts new file mode 100644 index 0000000..b84d761 --- /dev/null +++ b/apps/api/src/models/Community.ts @@ -0,0 +1,19 @@ +import { Schema, model } from "mongoose"; + +const communitySchema = new Schema( + { + // URL-safe, lowercase, unique: /c/ + slug: { type: String, required: true, unique: true, lowercase: true, trim: true }, + name: { type: String, required: true, trim: true, maxlength: 40 }, + description: { type: String, default: "", maxlength: 280 }, + creator: { type: Schema.Types.ObjectId, ref: "User", required: true, index: true }, + // Kept in step with Membership rows (like likeCount on posts) so lists need no counting. + memberCount: { type: Number, default: 0, min: 0 }, + }, + { timestamps: true }, +); + +// "Most popular first" lists. +communitySchema.index({ memberCount: -1, _id: -1 }); + +export const Community = model("Community", communitySchema); diff --git a/apps/api/src/models/Membership.ts b/apps/api/src/models/Membership.ts new file mode 100644 index 0000000..08f5edf --- /dev/null +++ b/apps/api/src/models/Membership.ts @@ -0,0 +1,16 @@ +import { Schema, model } from "mongoose"; + +const membershipSchema = new Schema( + { + user: { type: Schema.Types.ObjectId, ref: "User", required: true }, + community: { type: Schema.Types.ObjectId, ref: "Community", required: true }, + role: { type: String, enum: ["owner", "member"], default: "member" }, + }, + { timestamps: true }, +); + +// One membership per person per community; also makes joins safe under double clicks. +membershipSchema.index({ user: 1, community: 1 }, { unique: true }); +membershipSchema.index({ community: 1 }); + +export const Membership = model("Membership", membershipSchema); diff --git a/apps/api/src/models/Post.ts b/apps/api/src/models/Post.ts index d93ac99..7585c03 100644 --- a/apps/api/src/models/Post.ts +++ b/apps/api/src/models/Post.ts @@ -5,6 +5,8 @@ const postSchema = new Schema( author: { type: Schema.Types.ObjectId, ref: "User", required: true, index: true }, content: { type: String, trim: true, maxlength: 500, default: "" }, imageUrl: { type: String, default: null, index: true, sparse: true }, + // Set when the post was made inside a community. + community: { type: Schema.Types.ObjectId, ref: "Community", default: null, index: true }, // Paid posts: price in US cents and the content that is hidden until purchase. priceCents: { type: Number, default: null, min: 100 }, premiumContent: { type: String, default: null, maxlength: 5000 }, diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts index ffa1641..dd81581 100644 --- a/apps/api/src/routes/index.ts +++ b/apps/api/src/routes/index.ts @@ -1,7 +1,9 @@ import { Router } from "express"; import { banUserSchema, + communityListSchema, createCommentSchema, + createCommunitySchema, createPostSchema, createReportSchema, forgotPasswordSchema, @@ -20,6 +22,7 @@ import * as account from "../controllers/accountController.js"; import * as admin from "../controllers/adminController.js"; import * as auth from "../controllers/authController.js"; import * as payments from "../controllers/paymentController.js"; +import * as communities from "../controllers/communityController.js"; import * as notifications from "../controllers/notificationController.js"; import * as posts from "../controllers/postController.js"; import * as reports from "../controllers/reportController.js"; @@ -28,7 +31,7 @@ import * as search from "../controllers/searchController.js"; import * as uploads from "../controllers/uploadController.js"; import * as users from "../controllers/userController.js"; import { requireActive, requireAdmin, requireAuth } from "../middleware/auth.js"; -import { authLimiter, checkoutLimiter, emailLimiter, reportLimiter, publicLimiter, searchLimiter, uploadLimiter } from "../middleware/rateLimit.js"; +import { authLimiter, checkoutLimiter, communityLimiter, emailLimiter, reportLimiter, publicLimiter, searchLimiter, uploadLimiter } from "../middleware/rateLimit.js"; import { parseImageUpload } from "../middleware/upload.js"; import { validate } from "../middleware/validate.js"; import { asyncHandler } from "../utils/errors.js"; @@ -140,6 +143,31 @@ router.get( asyncHandler(search.posts), ); +// Communities +router.get( + "/communities", + requireAuth, + validate(communityListSchema, "query"), + asyncHandler(communities.list), +); +router.post( + "/communities", + requireAuth, + communityLimiter, + validate(createCommunitySchema), + requireActive, + asyncHandler(communities.create), +); +router.get("/communities/:slug", requireAuth, asyncHandler(communities.get)); +router.get( + "/communities/:slug/posts", + requireAuth, + validate(paginationSchema, "query"), + asyncHandler(communities.posts), +); +router.post("/communities/:slug/join", requireAuth, requireActive, asyncHandler(communities.join)); +router.delete("/communities/:slug/join", requireAuth, requireActive, asyncHandler(communities.leave)); + // Posts router.get("/posts", requireAuth, validate(paginationSchema, "query"), asyncHandler(posts.listPosts)); router.post( diff --git a/apps/api/src/tests/communities.test.ts b/apps/api/src/tests/communities.test.ts new file mode 100644 index 0000000..055fe3c --- /dev/null +++ b/apps/api/src/tests/communities.test.ts @@ -0,0 +1,197 @@ +import mongoose from "mongoose"; +import request from "supertest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createCommunitySchema, createPostSchema } from "@commune/shared"; + +// Removing a post touches many collections; here we only care that it was allowed. +vi.mock("../services/moderationService.js", async (importOriginal) => ({ + ...(await importOriginal()), + removePost: vi.fn().mockResolvedValue(undefined), +})); + +import { createApp } from "../app.js"; +import { Community } from "../models/Community.js"; +import { Membership } from "../models/Membership.js"; +import { Post } from "../models/Post.js"; +import { removePost } from "../services/moderationService.js"; +import { signAccessToken } from "../utils/tokens.js"; + +const app = createApp(); +const oid = () => new mongoose.Types.ObjectId(); +const ME = oid().toString(); +const auth = { Authorization: `Bearer ${signAccessToken(ME)}` }; +const chain = (value: unknown) => ({ select: () => ({ lean: () => Promise.resolve(value) }) }); + +const community = () => ({ + _id: oid(), + slug: "typescript", + name: "TypeScript", + description: "All things TS", + memberCount: 3, + createdAt: new Date(), +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("community schemas", () => { + it("normalises and validates slugs and names", () => { + expect(createCommunitySchema.parse({ slug: " TypeScript ", name: "TypeScript" }).slug).toBe( + "typescript", + ); + for (const slug of ["ab", "has space", "x".repeat(25), "bad-dash"]) { + expect(createCommunitySchema.safeParse({ slug, name: "Valid name" }).success).toBe(false); + } + expect(createCommunitySchema.safeParse({ slug: "okay", name: "ab" }).success).toBe(false); + }); + + it("lets a post name a community", () => { + expect(createPostSchema.parse({ content: "Hi", community: "TypeScript" }).community).toBe( + "typescript", + ); + }); +}); + +describe("POST /api/communities", () => { + const body = { slug: "typescript", name: "TypeScript", description: "All things TS" }; + + it("requires a login and valid input", async () => { + expect((await request(app).post("/api/communities").send(body)).status).toBe(401); + const bad = await request(app).post("/api/communities").set(auth).send({ slug: "x", name: "y" }); + expect(bad.status).toBe(400); + }); + + it("creates the community and makes the creator its owner", async () => { + vi.spyOn(Community, "countDocuments").mockResolvedValue(0 as never); + const created = community(); + const create = vi.spyOn(Community, "create").mockResolvedValue(created as never); + const member = vi.spyOn(Membership, "create").mockResolvedValue({} as never); + + const res = await request(app).post("/api/communities").set(auth).send(body); + expect(res.status).toBe(201); + expect(res.body).toMatchObject({ slug: "typescript", joinedByMe: true, myRole: "owner" }); + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ slug: "typescript", creator: ME, memberCount: 1 }), + ); + expect(member).toHaveBeenCalledWith({ user: ME, community: created._id, role: "owner" }); + }); + + it("answers 409 for a taken address and 403 past the per-user limit", async () => { + vi.spyOn(Community, "countDocuments").mockResolvedValue(0 as never); + vi.spyOn(Community, "create").mockRejectedValue(Object.assign(new Error("dup"), { code: 11000 })); + expect((await request(app).post("/api/communities").set(auth).send(body)).status).toBe(409); + + vi.spyOn(Community, "countDocuments").mockResolvedValue(5 as never); + expect((await request(app).post("/api/communities").set(auth).send(body)).status).toBe(403); + }); +}); + +describe("GET /api/communities", () => { + it("lists with my role, and searches with an escaped pattern", async () => { + const c = community(); + vi.spyOn(Membership, "find").mockReturnValue( + chain([{ community: c._id, role: "member" }]) as never, + ); + const find = vi.spyOn(Community, "find").mockReturnValue({ + sort: () => ({ limit: () => ({ lean: () => Promise.resolve([c]) }) }), + } as never); + + const res = await request(app).get("/api/communities?q=type.script").set(auth); + expect(res.status).toBe(200); + expect(res.body[0]).toMatchObject({ slug: "typescript", joinedByMe: true, myRole: "member" }); + expect(find).toHaveBeenCalledWith({ + $or: [ + { slug: { $regex: "type\\.script", $options: "i" } }, + { name: { $regex: "type\\.script", $options: "i" } }, + ], + }); + }); +}); + +describe("joining and leaving", () => { + it("404s for an unknown community", async () => { + vi.spyOn(Community, "findOne").mockReturnValue({ lean: () => Promise.resolve(null) } as never); + expect((await request(app).post("/api/communities/nope/join").set(auth)).status).toBe(404); + }); + + it("joins and counts the new member once", async () => { + const c = community(); + vi.spyOn(Community, "findOne").mockReturnValue({ lean: () => Promise.resolve(c) } as never); + vi.spyOn(Membership, "create").mockResolvedValue({} as never); + const update = vi.spyOn(Community, "updateOne").mockResolvedValue({} as never); + const res = await request(app).post("/api/communities/typescript/join").set(auth); + expect(res.status).toBe(204); + expect(update).toHaveBeenCalledWith({ _id: c._id }, { $inc: { memberCount: 1 } }); + }); + + it("treats a repeated join as success without counting again", async () => { + vi.spyOn(Community, "findOne").mockReturnValue({ lean: () => Promise.resolve(community()) } as never); + vi.spyOn(Membership, "create").mockRejectedValue(Object.assign(new Error("dup"), { code: 11000 })); + const update = vi.spyOn(Community, "updateOne").mockResolvedValue({} as never); + const res = await request(app).post("/api/communities/typescript/join").set(auth); + expect(res.status).toBe(204); + expect(update).not.toHaveBeenCalled(); + }); + + it("stops owners from leaving", async () => { + vi.spyOn(Community, "findOne").mockReturnValue({ lean: () => Promise.resolve(community()) } as never); + vi.spyOn(Membership, "findOne").mockReturnValue(chain({ role: "owner" }) as never); + const res = await request(app).delete("/api/communities/typescript/join").set(auth); + expect(res.status).toBe(400); + }); + + it("lets members leave and updates the count", async () => { + const c = community(); + vi.spyOn(Community, "findOne").mockReturnValue({ lean: () => Promise.resolve(c) } as never); + vi.spyOn(Membership, "findOne").mockReturnValue(chain({ role: "member" }) as never); + vi.spyOn(Membership, "findOneAndDelete").mockResolvedValue({} as never); + const update = vi.spyOn(Community, "updateOne").mockResolvedValue({} as never); + const res = await request(app).delete("/api/communities/typescript/join").set(auth); + expect(res.status).toBe(204); + expect(update).toHaveBeenCalledWith({ _id: c._id }, { $inc: { memberCount: -1 } }); + }); +}); + +describe("posting in a community", () => { + it("is refused for non-members", async () => { + vi.spyOn(Community, "findOne").mockReturnValue(chain({ _id: oid() }) as never); + vi.spyOn(Membership, "exists").mockResolvedValue(null as never); + const res = await request(app) + .post("/api/posts") + .set(auth) + .send({ content: "Hello", community: "typescript" }); + expect(res.status).toBe(403); + }); + + it("404s for an unknown community", async () => { + vi.spyOn(Community, "findOne").mockReturnValue(chain(null) as never); + const res = await request(app) + .post("/api/posts") + .set(auth) + .send({ content: "Hello", community: "nope" }); + expect(res.status).toBe(404); + }); +}); + +describe("deleting posts in a community", () => { + const postId = oid().toString(); + + it("lets the community owner remove someone else's post", async () => { + vi.spyOn(Post, "findById").mockReturnValue(chain({ author: oid(), community: oid() }) as never); + const exists = vi.spyOn(Membership, "exists").mockResolvedValue({ _id: oid() } as never); + const res = await request(app).delete(`/api/posts/${postId}`).set(auth); + expect(res.status).toBe(204); + expect(exists).toHaveBeenCalledWith(expect.objectContaining({ user: ME, role: "owner" })); + expect(removePost).toHaveBeenCalled(); + }); + + it("still refuses everyone else", async () => { + vi.mocked(removePost).mockClear(); + vi.spyOn(Post, "findById").mockReturnValue(chain({ author: oid(), community: oid() }) as never); + vi.spyOn(Membership, "exists").mockResolvedValue(null as never); + const res = await request(app).delete(`/api/posts/${postId}`).set(auth); + expect(res.status).toBe(403); + expect(removePost).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/tests/google.test.ts b/apps/api/src/tests/google.test.ts index 3405017..0c71443 100644 --- a/apps/api/src/tests/google.test.ts +++ b/apps/api/src/tests/google.test.ts @@ -136,7 +136,7 @@ describe("POST /api/auth/google: accounts", () => { it("links an existing password account and cancels a password set before verification", async () => { const save = vi.fn().mockResolvedValue(undefined); - const existing = newUser({ emailVerified: false, passwordHash: "old-hash", googleId: undefined, save }); + const existing: Record = newUser({ emailVerified: false, passwordHash: "old-hash", googleId: undefined, save }); vi.spyOn(User, "findOne").mockImplementation(((filter: Record) => Promise.resolve("googleId" in filter ? null : existing)) as never); @@ -154,4 +154,4 @@ describe("POST /api/auth/google: accounts", () => { const res = await post(idToken()); expect(res.status).toBe(403); }); -}); +}); \ No newline at end of file diff --git a/apps/api/src/utils/serializers.ts b/apps/api/src/utils/serializers.ts index 41e0e51..838da45 100644 --- a/apps/api/src/utils/serializers.ts +++ b/apps/api/src/utils/serializers.ts @@ -1,6 +1,7 @@ import type { Types } from "mongoose"; import type { CommentDTO, + CommunityDTO, NotificationDTO, NotificationType, PostDTO, @@ -34,6 +35,7 @@ export interface PopulatedPost { imageUrl?: string | null; priceCents?: number | null; premiumContent?: string | null; + community?: { slug: string; name: string } | null; author: PopulatedAuthor; likeCount: number; commentCount: number; @@ -114,6 +116,7 @@ export function toPostDTO(p: PopulatedPost, likedByMe: boolean, unlocked: boolea likeCount: p.likeCount, commentCount: p.commentCount, likedByMe, + community: p.community ? { slug: p.community.slug, name: p.community.name } : null, priceCents: p.priceCents ?? null, locked, premiumContent: paid && !locked ? (p.premiumContent ?? "") : null, @@ -161,3 +164,28 @@ export function toReportDTO(r: PopulatedReport): ReportDTO { resolvedAt: r.resolvedAt ? r.resolvedAt.toISOString() : null, }; } + +export interface CommunityLike { + _id: Types.ObjectId; + slug: string; + name: string; + description?: string | null; + memberCount: number; + createdAt: Date; +} + +export function toCommunityDTO( + c: CommunityLike, + myRole: "owner" | "member" | null, +): CommunityDTO { + return { + id: c._id.toString(), + slug: c.slug, + name: c.name, + description: c.description ?? "", + memberCount: c.memberCount, + joinedByMe: myRole !== null, + myRole, + createdAt: c.createdAt.toISOString(), + }; +} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index d57b67c..01297db 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -18,6 +18,8 @@ import SearchPage from "./pages/SearchPage"; import SearchBox from "./components/SearchBox"; import PwaBanner from "./components/PwaBanner"; import ExplorePage from "./pages/ExplorePage"; +import CommunitiesPage from "./pages/CommunitiesPage"; +import CommunityPage from "./pages/CommunityPage"; function Protected({ children }: { children: ReactNode }) { const { user, loading } = useAuth(); @@ -60,6 +62,9 @@ function Header() { Admin )} + + Communities + + + + + } + /> + + + + } + /> (null); const [imageError, setImageError] = useState(null); @@ -45,7 +45,7 @@ export default function Composer() { e.preventDefault(); if (!canPost) return; create.mutate( - { content, imageUrl, ...(paid ? { priceCents, premiumContent: premium } : {}) }, + { content, imageUrl, ...(communitySlug ? { community: communitySlug } : {}), ...(paid ? { priceCents, premiumContent: premium } : {}) }, { onSuccess: () => { setContent(""); @@ -62,7 +62,7 @@ export default function Composer() {