diff --git a/apps/api/drizzle/0000_baseline.sql b/apps/api/drizzle/0000_baseline.sql index d880fe28f..855a15ed1 100644 --- a/apps/api/drizzle/0000_baseline.sql +++ b/apps/api/drizzle/0000_baseline.sql @@ -391,6 +391,7 @@ CREATE TABLE "community_comments" ( "parent_comment_id" uuid, "school_account_id" uuid, "content" text NOT NULL, + "edited_at" timestamp with time zone, "deleted_at" timestamp with time zone, "created_at" timestamp with time zone NOT NULL, "updated_at" timestamp with time zone NOT NULL, @@ -417,6 +418,7 @@ CREATE TABLE "community_posts" ( "space_id" uuid, "gif_urls" jsonb DEFAULT '[]'::jsonb NOT NULL, "pinned" boolean DEFAULT false NOT NULL, + "edited_at" timestamp with time zone, "deleted_at" timestamp with time zone, "created_at" timestamp with time zone NOT NULL, "updated_at" timestamp with time zone NOT NULL, diff --git a/apps/api/drizzle/meta/0000_snapshot.json b/apps/api/drizzle/meta/0000_snapshot.json index 82a9afd85..28a76829b 100644 --- a/apps/api/drizzle/meta/0000_snapshot.json +++ b/apps/api/drizzle/meta/0000_snapshot.json @@ -3485,6 +3485,12 @@ "primaryKey": false, "notNull": true }, + "edited_at": { + "name": "edited_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, "deleted_at": { "name": "deleted_at", "type": "timestamp with time zone", @@ -3793,6 +3799,12 @@ "notNull": true, "default": false }, + "edited_at": { + "name": "edited_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, "deleted_at": { "name": "deleted_at", "type": "timestamp with time zone", diff --git a/apps/api/openapi.json b/apps/api/openapi.json index c3d6a846c..998c0890a 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -5150,6 +5150,10 @@ "pinned": { "type": "boolean" }, + "editedAt": { + "type": "string", + "nullable": true + }, "deletedAt": { "type": "string", "nullable": true @@ -5176,6 +5180,7 @@ "commentsCount", "subscribed", "pinned", + "editedAt", "deletedAt", "createdAt", "updatedAt" @@ -5656,6 +5661,10 @@ "pinned": { "type": "boolean" }, + "editedAt": { + "type": "string", + "nullable": true + }, "deletedAt": { "type": "string", "nullable": true @@ -5682,6 +5691,7 @@ "commentsCount", "subscribed", "pinned", + "editedAt", "deletedAt", "createdAt", "updatedAt" @@ -6152,6 +6162,10 @@ "pinned": { "type": "boolean" }, + "editedAt": { + "type": "string", + "nullable": true + }, "deletedAt": { "type": "string", "nullable": true @@ -6178,6 +6192,7 @@ "commentsCount", "subscribed", "pinned", + "editedAt", "deletedAt", "createdAt", "updatedAt" @@ -6836,6 +6851,10 @@ ] } }, + "editedAt": { + "type": "string", + "nullable": true + }, "deletedAt": { "type": "string", "nullable": true @@ -6858,6 +6877,7 @@ "content", "media", "reactions", + "editedAt", "deletedAt", "createdAt", "updatedAt" @@ -7306,6 +7326,10 @@ ] } }, + "editedAt": { + "type": "string", + "nullable": true + }, "deletedAt": { "type": "string", "nullable": true @@ -7328,6 +7352,7 @@ "content", "media", "reactions", + "editedAt", "deletedAt", "createdAt", "updatedAt" @@ -7762,6 +7787,10 @@ ] } }, + "editedAt": { + "type": "string", + "nullable": true + }, "deletedAt": { "type": "string", "nullable": true @@ -7784,6 +7813,7 @@ "content", "media", "reactions", + "editedAt", "deletedAt", "createdAt", "updatedAt" @@ -14670,6 +14700,10 @@ "pinned": { "type": "boolean" }, + "editedAt": { + "type": "string", + "nullable": true + }, "deletedAt": { "type": "string", "nullable": true @@ -14798,6 +14832,7 @@ "commentsCount", "subscribed", "pinned", + "editedAt", "deletedAt", "createdAt", "updatedAt", @@ -15190,6 +15225,10 @@ "pinned": { "type": "boolean" }, + "editedAt": { + "type": "string", + "nullable": true + }, "deletedAt": { "type": "string", "nullable": true @@ -15318,6 +15357,7 @@ "commentsCount", "subscribed", "pinned", + "editedAt", "deletedAt", "createdAt", "updatedAt", @@ -16113,6 +16153,10 @@ "pinned": { "type": "boolean" }, + "editedAt": { + "type": "string", + "nullable": true + }, "deletedAt": { "type": "string", "nullable": true @@ -16139,6 +16183,7 @@ "commentsCount", "subscribed", "pinned", + "editedAt", "deletedAt", "createdAt", "updatedAt" @@ -16612,6 +16657,10 @@ "pinned": { "type": "boolean" }, + "editedAt": { + "type": "string", + "nullable": true + }, "deletedAt": { "type": "string", "nullable": true @@ -16638,6 +16687,7 @@ "commentsCount", "subscribed", "pinned", + "editedAt", "deletedAt", "createdAt", "updatedAt" @@ -17052,6 +17102,10 @@ "pinned": { "type": "boolean" }, + "editedAt": { + "type": "string", + "nullable": true + }, "deletedAt": { "type": "string", "nullable": true @@ -17078,6 +17132,7 @@ "commentsCount", "subscribed", "pinned", + "editedAt", "deletedAt", "createdAt", "updatedAt" @@ -17461,6 +17516,10 @@ ] } }, + "editedAt": { + "type": "string", + "nullable": true + }, "deletedAt": { "type": "string", "nullable": true @@ -17483,6 +17542,7 @@ "content", "media", "reactions", + "editedAt", "deletedAt", "createdAt", "updatedAt" @@ -17924,6 +17984,10 @@ ] } }, + "editedAt": { + "type": "string", + "nullable": true + }, "deletedAt": { "type": "string", "nullable": true @@ -17946,6 +18010,7 @@ "content", "media", "reactions", + "editedAt", "deletedAt", "createdAt", "updatedAt" @@ -18373,6 +18438,10 @@ ] } }, + "editedAt": { + "type": "string", + "nullable": true + }, "deletedAt": { "type": "string", "nullable": true @@ -18395,6 +18464,7 @@ "content", "media", "reactions", + "editedAt", "deletedAt", "createdAt", "updatedAt" diff --git a/apps/api/src/communities.ts b/apps/api/src/communities.ts index c12ad1dcc..c831a2730 100644 --- a/apps/api/src/communities.ts +++ b/apps/api/src/communities.ts @@ -39,6 +39,7 @@ import { ensureLearnerAccountByEmail } from "./learners.js"; import { queueCommunityInviteEmail } from "./school-emails.js"; import { queueMembershipEvents, schoolSiteUrl } from "./membership-events.js"; import * as schema from "./db/schema/index.js"; +import { commentWasEdited, postWasEdited } from "./community-edits.js"; import { findLearnerMembership, type LearnerMembershipStatus, @@ -164,6 +165,7 @@ export type CommunityPostDto = { commentsCount: number; subscribed: boolean; pinned: boolean; + editedAt: string | null; deletedAt: string | null; createdAt: string; updatedAt: string; @@ -201,6 +203,7 @@ export type CommunityCommentDto = { content: string; media: CommunityMediaDto[]; reactions: CommunityReactionDto[]; + editedAt: string | null; deletedAt: string | null; createdAt: string; updatedAt: string; @@ -725,6 +728,7 @@ function postToDto( commentsCount, subscribed, pinned: row.pinned, + editedAt: row.editedAt ? serializeDate(row.editedAt) : null, deletedAt: row.deletedAt ? serializeDate(row.deletedAt) : null, createdAt: serializeDate(row.createdAt), updatedAt: serializeDate(row.updatedAt), @@ -753,6 +757,7 @@ function commentToDto( content: row.deletedAt ? DELETED_COMMUNITY_CONTENT : row.content, media, reactions, + editedAt: row.editedAt ? serializeDate(row.editedAt) : null, deletedAt: row.deletedAt ? serializeDate(row.deletedAt) : null, createdAt: serializeDate(row.createdAt), updatedAt: serializeDate(row.updatedAt), @@ -3497,6 +3502,7 @@ export async function createPost( gifUrls: communityGifUrls(input.gifUrls ?? []), pinned: false, spaceId: null, + editedAt: null, deletedAt: null, createdAt: now, updatedAt: now, @@ -3713,6 +3719,23 @@ export async function updatePost( } if (!next.title || !communityContentHasText(next.content)) return { ok: false, error: createPlatformError("validation_failed") }; + const mediaBefore = + input.mediaIds === undefined + ? [] + : ( + (await communityMediaMap(db, school.schoolId, [existing.id])).get(existing.id) ?? + [] + ).map((media) => media.id); + const edited = postWasEdited( + { + title: existing.title, + content: existing.content, + gifUrls: communityGifUrls(existing.gifUrls), + }, + next, + { before: mediaBefore, after: input.mediaIds }, + ); + const saved = { ...next, editedAt: edited ? now : existing.editedAt }; const richTextMediaIds = input.content !== undefined ? await mediaIdsForRichTextContent(db, school.schoolId, input.content) @@ -3738,7 +3761,7 @@ export async function updatePost( } await db .update(schema.communityPosts) - .set(next) + .set(saved) .where(eq(schema.communityPosts.id, existing.id)); const reactions = await communityReactionMap( db, @@ -3761,7 +3784,7 @@ export async function updatePost( return { ok: true, value: postToDto( - { ...existing, ...next }, + { ...existing, ...saved }, existing.schoolAccountId ? (await learnerPublicIds(db, [existing.schoolAccountId])).get( existing.schoolAccountId, @@ -3972,6 +3995,7 @@ export async function createComment( parentCommentId, ...identityColumns(identity), content: input.content, + editedAt: null, deletedAt: null, createdAt: now, updatedAt: now, @@ -4121,6 +4145,16 @@ export async function updateComment( if (!(await authorCanChange(viewer, existing.schoolAccountId))) return forbidden(); if (input.content.length > 20_000 || !communityContentHasText(input.content)) return { ok: false, error: createPlatformError("validation_failed") }; + const mediaBefore = + input.mediaIds === undefined + ? [] + : ((await communityMediaMap(db, schoolId, [existing.id])).get(existing.id) ?? []).map( + (media) => media.id, + ); + const edited = commentWasEdited(existing, input, { + before: mediaBefore, + after: input.mediaIds, + }); const richTextMediaIds = await mediaIdsForRichTextContent( db, schoolId, @@ -4149,7 +4183,12 @@ export async function updateComment( ); if (!references.ok) return references; } - const next = { content: input.content, updatedAt: clock.now() }; + const now = clock.now(); + const next = { + content: input.content, + updatedAt: now, + editedAt: edited ? now : existing.editedAt, + }; await db .update(schema.communityComments) .set(next) diff --git a/apps/api/src/community-edits.test.ts b/apps/api/src/community-edits.test.ts new file mode 100644 index 000000000..c004b2d7b --- /dev/null +++ b/apps/api/src/community-edits.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "bun:test"; +import { commentWasEdited, postWasEdited } from "./community-edits.js"; + +const post = { title: "Hello", content: "Body", gifUrls: ["a.gif"] }; + +describe("postWasEdited", () => { + it("is false when nothing readers see changed", () => { + expect(postWasEdited(post, { ...post }, { before: ["m1"], after: undefined })).toBe( + false, + ); + expect( + postWasEdited(post, { ...post }, { before: ["m1", "m2"], after: ["m2", "m1"] }), + ).toBe(false); + }); + + it("is true when the title, content, GIFs or media change", () => { + const none = { before: [], after: undefined }; + expect(postWasEdited(post, { ...post, title: "Hi" }, none)).toBe(true); + expect(postWasEdited(post, { ...post, content: "New" }, none)).toBe(true); + expect(postWasEdited(post, { ...post, gifUrls: [] }, none)).toBe(true); + expect(postWasEdited(post, { ...post }, { before: ["m1"], after: [] })).toBe(true); + }); +}); + +describe("commentWasEdited", () => { + it("follows the content and media", () => { + const none = { before: [], after: undefined }; + expect(commentWasEdited({ content: "a" }, { content: "a" }, none)).toBe(false); + expect(commentWasEdited({ content: "a" }, { content: "b" }, none)).toBe(true); + expect( + commentWasEdited( + { content: "a" }, + { content: "a" }, + { before: [], after: ["m1"] }, + ), + ).toBe(true); + }); +}); diff --git a/apps/api/src/community-edits.ts b/apps/api/src/community-edits.ts new file mode 100644 index 000000000..a41921227 --- /dev/null +++ b/apps/api/src/community-edits.ts @@ -0,0 +1,42 @@ +/** + * Whether saving a post or comment changes what readers see, so it earns the + * "Edited" label. Saving without changes, pinning, moving between spaces and + * moderation don't count. + */ + +/** Same ids, ignoring order and duplicates. */ +function sameIds(a: readonly string[], b: readonly string[]): boolean { + const left = new Set(a); + const right = new Set(b); + return left.size === right.size && [...left].every((id) => right.has(id)); +} + +function sameList(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((item, index) => item === b[index]); +} + +type Media = { before: readonly string[]; after: readonly string[] | undefined }; + +export function postWasEdited( + before: { title: string; content: string; gifUrls: readonly string[] }, + after: { title: string; content: string; gifUrls: readonly string[] }, + media: Media, +): boolean { + return ( + before.title !== after.title || + before.content !== after.content || + !sameList(before.gifUrls, after.gifUrls) || + (media.after !== undefined && !sameIds(media.before, media.after)) + ); +} + +export function commentWasEdited( + before: { content: string }, + after: { content: string }, + media: Media, +): boolean { + return ( + before.content !== after.content || + (media.after !== undefined && !sameIds(media.before, media.after)) + ); +} diff --git a/apps/api/src/db/schema/communities.ts b/apps/api/src/db/schema/communities.ts index dba98c9fa..fb0355d19 100644 --- a/apps/api/src/db/schema/communities.ts +++ b/apps/api/src/db/schema/communities.ts @@ -70,6 +70,8 @@ export const communityPosts = pgTable( /** External GIF links picked from GIF search; never media-library items. */ gifUrls: jsonb("gif_urls").$type().notNull().default([]), pinned: boolean("pinned").notNull().default(false), + /** Set when the author changes the post, for its "Edited" label. */ + editedAt: timestamp("edited_at", { withTimezone: true }), deletedAt: timestamp("deleted_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }).notNull(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(), @@ -114,6 +116,8 @@ export const communityComments = pgTable( onDelete: "set null", }), content: text("content").notNull(), + /** Set when the author changes the comment, for its "Edited" label. */ + editedAt: timestamp("edited_at", { withTimezone: true }), deletedAt: timestamp("deleted_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }).notNull(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(), diff --git a/apps/api/src/spaces.test.ts b/apps/api/src/spaces.test.ts index 6dd846a9f..a5eb3fb75 100644 --- a/apps/api/src/spaces.test.ts +++ b/apps/api/src/spaces.test.ts @@ -908,8 +908,14 @@ describe.serial("school spaces and included products", () => { body, }); expect((await editPost(buyerHeaders, { pinned: true })).status).toBe(403); + // Saving without a change doesn't mark the post edited. + const unchanged = await editPost(buyerHeaders, { title: "Spam" }); + expect(unchanged.status).toBe(200); + expect((unchanged.body as { editedAt: string | null }).editedAt).toBeNull(); const edited = await editPost(buyerHeaders, { title: "Spam, edited" }); expect(edited.status).toBe(200); + const postEditedAt = (edited.body as { editedAt: string | null }).editedAt; + expect(postEditedAt).toEqual(expect.any(String)); // Back-date the post so a pin that touched the date would show. const editedAt = "2026-01-01T00:00:00.000Z"; await runtime.db @@ -925,6 +931,7 @@ describe.serial("school spaces and included products", () => { expect(pinned.body).toMatchObject({ pinned: true, title: "Spam, edited" }); // Pinning isn't an edit, so the post keeps its date. expect((pinned.body as { updatedAt: string }).updatedAt).toBe(editedAt); + expect((pinned.body as { editedAt: string | null }).editedAt).toBe(postEditedAt); expect((await editPost(headers, { pinned: false })).body).toMatchObject({ pinned: false, }); @@ -942,6 +949,7 @@ describe.serial("school spaces and included products", () => { body: { content: commentContent("First take") }, }); expect(commented.status).toBe(201); + expect((commented.body as { editedAt: string | null }).editedAt).toBeNull(); const commentId = (commented.body as { id: string }).id; const editComment = (requestHeaders: Record, text: string) => dispatch(runtime, { @@ -953,6 +961,9 @@ describe.serial("school spaces and included products", () => { const editedComment = await editComment(buyerHeaders, "Second take"); expect(editedComment.status).toBe(200); expect((editedComment.body as { content: string }).content).toContain("Second take"); + expect((editedComment.body as { editedAt: string | null }).editedAt).toEqual( + expect.any(String), + ); expect((await editComment(headers, "Not yours")).status).toBe(403); const removed = await dispatch(runtime, { diff --git a/apps/api/src/spaces.ts b/apps/api/src/spaces.ts index 7891e8c0e..e057da7ea 100644 --- a/apps/api/src/spaces.ts +++ b/apps/api/src/spaces.ts @@ -836,6 +836,7 @@ export async function createLearnerSpacePost( category: space.name, gifUrls: communityGifUrls(input.gifUrls ?? []), pinned: false, + editedAt: null, deletedAt: null, createdAt: now, updatedAt: now, diff --git a/apps/storefront/components/communities/learner-community.tsx b/apps/storefront/components/communities/learner-community.tsx index fce089109..ae0e81825 100644 --- a/apps/storefront/components/communities/learner-community.tsx +++ b/apps/storefront/components/communities/learner-community.tsx @@ -82,9 +82,22 @@ import { } from "./learner-gif-picker"; import { LEARNER_ACCESS_CHANGED } from "@/lib/learner-access-events"; import { LearnerPostComposerDialog } from "./learner-post-composer-dialog"; +import { editedNote } from "@/lib/community-edited"; import { LearnerRichTextEditor } from "./learner-rich-text-editor"; type Learner = z.infer; + +/** " · Edited", with when in its tooltip, after a post's or comment's date. */ +function EditedNote({ editedAt }: { editedAt: string | null | undefined }) { + const note = editedNote(editedAt); + if (!note) return null; + return ( + <> + {" · "} + {note.label} + + ); +} type Community = z.infer; type CommunityPost = z.infer; type CommunityComment = z.infer; @@ -1827,10 +1840,10 @@ export function LearnerCommunity({ : "Community member")} - {" "} - · {post.category} + + · {post.category} @@ -2020,6 +2033,9 @@ export function LearnerCommunity({ {new Date( comment.createdAt, ).toLocaleDateString()} + {!comment.deletedAt ? ( + + ) : null} {!comment.deletedAt ? ( diff --git a/apps/storefront/components/dashboard/learner-feed.tsx b/apps/storefront/components/dashboard/learner-feed.tsx index 5d49afad7..7788640ba 100644 --- a/apps/storefront/components/dashboard/learner-feed.tsx +++ b/apps/storefront/components/dashboard/learner-feed.tsx @@ -29,6 +29,7 @@ import { type LearnerCommunityMedia, useLearnerCommunityMediaUploader, } from "@/lib/community-media-uploader"; +import { editedNote } from "@/lib/community-edited"; import { requestJson } from "@/lib/request-json"; import { useSchoolThemeStyle } from "@/lib/school-theme-context"; import { LearnerSpacePost } from "@/components/communities/learner-community"; @@ -97,6 +98,7 @@ const feedPostSchema = z.object({ pinned: z.boolean().optional(), createdAt: z.string().optional(), updatedAt: z.string().optional(), + editedAt: z.string().nullable().optional(), spaceId: z.string().nullable().optional(), space: z .object({ @@ -347,7 +349,8 @@ function FeedPostCard({ }) { const postSpace = post.space ?? space; const authorName = post.author?.name || post.author?.email || "Learner"; - const date = formatPostDate(post.updatedAt ?? post.createdAt); + const date = formatPostDate(post.createdAt ?? post.updatedAt); + const edited = editedNote(post.editedAt); const spaceName = postSpace?.name ?? post.category ?? "Space"; return ( @@ -369,7 +372,14 @@ function FeedPostCard({
{authorName} - {date ? `${date} · ${spaceName}` : spaceName} + {date ? `${date} · ` : null} + {edited ? ( + <> + {edited.label} + {" · "} + + ) : null} + {spaceName}
{post.pinned ? ( diff --git a/apps/storefront/lib/community-edited.test.ts b/apps/storefront/lib/community-edited.test.ts new file mode 100644 index 000000000..c2b52c952 --- /dev/null +++ b/apps/storefront/lib/community-edited.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "bun:test"; +import { editedNote } from "./community-edited"; + +describe("editedNote", () => { + it("is empty for content that was never edited", () => { + expect(editedNote(null)).toBeNull(); + expect(editedNote(undefined)).toBeNull(); + expect(editedNote("")).toBeNull(); + }); + + it("ignores a date it can't read", () => { + expect(editedNote("not a date")).toBeNull(); + }); + + it("labels edited content and gives the time in its title", () => { + const note = editedNote("2026-03-06T10:30:00.000Z", "en-US"); + expect(note?.label).toBe("Edited"); + expect(note?.title).toStartWith("Edited Mar 6, 2026"); + }); +}); diff --git a/apps/storefront/lib/community-edited.ts b/apps/storefront/lib/community-edited.ts new file mode 100644 index 000000000..2a618fba7 --- /dev/null +++ b/apps/storefront/lib/community-edited.ts @@ -0,0 +1,17 @@ +/** + * The "Edited" note shown next to a community post's or comment's date. + * Null when it was never edited or the date can't be read. + */ +export function editedNote( + editedAt: string | null | undefined, + locale?: string, +): { label: string; title: string } | null { + if (!editedAt) return null; + const date = new Date(editedAt); + if (Number.isNaN(date.getTime())) return null; + const when = new Intl.DateTimeFormat(locale, { + dateStyle: "medium", + timeStyle: "short", + }).format(date); + return { label: "Edited", title: `Edited ${when}` }; +} diff --git a/packages/api-contract/src/index.ts b/packages/api-contract/src/index.ts index 60ddc4a48..f98bee186 100644 --- a/packages/api-contract/src/index.ts +++ b/packages/api-contract/src/index.ts @@ -1361,6 +1361,8 @@ export const communityPostSchema = z.object({ commentsCount: z.number().int().nonnegative(), subscribed: z.boolean(), pinned: z.boolean(), + /** When the author last changed it; null if never edited. */ + editedAt: z.string().nullable(), deletedAt: z.string().nullable(), createdAt: z.string(), updatedAt: z.string(), @@ -1397,6 +1399,8 @@ export const communityCommentSchema = z.object({ content: z.string(), media: z.array(communityMediaSchema), reactions: z.array(communityReactionSchema), + /** When the author last changed it; null if never edited. */ + editedAt: z.string().nullable(), deletedAt: z.string().nullable(), createdAt: z.string(), updatedAt: z.string(),