diff --git a/TODO.md b/TODO.md index e660023..bb19174 100644 --- a/TODO.md +++ b/TODO.md @@ -9,3 +9,5 @@ - [ ] Visibility indicator on journal entries - [ ] Hide journal entries from feed that are hidden unless current user is a moderator - [ ] Edit/delete journal entries? +- [ ] Journal entry comments +- [ ] A Forum diff --git a/db/schema.ts b/db/schema.ts index ad9ef68..6d2be40 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -38,6 +38,18 @@ export const journalEntries = pgTable("journal_entries", { date: timestamp().notNull() }); +export const journalComments = pgTable("journal_comments", { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + user: integer() + .references(() => users.id, { onDelete: "cascade" }) + .notNull(), + entry: integer() + .references(() => journalEntries.id, { onDelete: "cascade" }) + .notNull(), + content: varchar({ length: 512 }).notNull(), + date: timestamp().notNull() +}); + export const profiles = pgTable("profiles", { user: integer() .references(() => users.id, { onDelete: "cascade" }) diff --git a/drizzle/0003_closed_alex_power.sql b/drizzle/0003_closed_alex_power.sql new file mode 100644 index 0000000..c33ef93 --- /dev/null +++ b/drizzle/0003_closed_alex_power.sql @@ -0,0 +1,19 @@ +CREATE TABLE IF NOT EXISTS "journal_comments" ( + "id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "journal_comments_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), + "user" integer NOT NULL, + "entry" integer NOT NULL, + "content" varchar(512) NOT NULL, + "date" timestamp NOT NULL +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "journal_comments" ADD CONSTRAINT "journal_comments_user_users_id_fk" FOREIGN KEY ("user") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "journal_comments" ADD CONSTRAINT "journal_comments_entry_journal_entries_id_fk" FOREIGN KEY ("entry") REFERENCES "public"."journal_entries"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..c899e4d --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -0,0 +1,496 @@ +{ + "id": "0c1bcfed-899c-4ec9-9a7d-c987aa50dfb8", + "prevId": "f8e1fcdc-dfa2-41cd-886a-5f29a1c091df", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.follows": { + "name": "follows", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "follower_id": { + "name": "follower_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "follows_user_id_users_id_fk": { + "name": "follows_user_id_users_id_fk", + "tableFrom": "follows", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "follows_follower_id_users_id_fk": { + "name": "follows_follower_id_users_id_fk", + "tableFrom": "follows", + "tableTo": "users", + "columnsFrom": [ + "follower_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "follows_user_id_follower_id_pk": { + "name": "follows_user_id_follower_id_pk", + "columns": [ + "user_id", + "follower_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invite_codes": { + "name": "invite_codes", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "varchar(22)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "granted": { + "name": "granted", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "'1970-01-01 00:00:00.000'" + }, + "confers": { + "name": "confers", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "invite_codes_user_id_users_id_fk": { + "name": "invite_codes_user_id_users_id_fk", + "tableFrom": "invite_codes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.journal_comments": { + "name": "journal_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "journal_comments_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "user": { + "name": "user", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "entry": { + "name": "entry", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "journal_comments_user_users_id_fk": { + "name": "journal_comments_user_users_id_fk", + "tableFrom": "journal_comments", + "tableTo": "users", + "columnsFrom": [ + "user" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "journal_comments_entry_journal_entries_id_fk": { + "name": "journal_comments_entry_journal_entries_id_fk", + "tableFrom": "journal_comments", + "tableTo": "journal_entries", + "columnsFrom": [ + "entry" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.journal_entries": { + "name": "journal_entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "journal_entries_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "user": { + "name": "user", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mood-change": { + "name": "mood-change", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "title": { + "name": "title", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "entry": { + "name": "entry", + "type": "varchar(4096)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "visibility": { + "name": "visibility", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "date": { + "name": "date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "journal_entries_user_users_id_fk": { + "name": "journal_entries_user_users_id_fk", + "tableFrom": "journal_entries", + "tableTo": "users", + "columnsFrom": [ + "user" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.profiles": { + "name": "profiles", + "schema": "", + "columns": { + "user": { + "name": "user", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "bio": { + "name": "bio", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "website": { + "name": "website", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "''" + } + }, + "indexes": {}, + "foreignKeys": { + "profiles_user_users_id_fk": { + "name": "profiles_user_users_id_fk", + "tableFrom": "profiles", + "tableTo": "users", + "columnsFrom": [ + "user" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.updates": { + "name": "updates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "updates_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "user": { + "name": "user", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mood": { + "name": "mood", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "description": { + "name": "description", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "date": { + "name": "date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "updates_user_users_id_fk": { + "name": "updates_user_users_id_fk", + "tableFrom": "updates", + "tableTo": "users", + "columnsFrom": [ + "user" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "users_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "email": { + "name": "email", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(26)", + "primaryKey": false, + "notNull": true + }, + "pass": { + "name": "pass", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "registered": { + "name": "registered", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "users_name_unique": { + "name": "users_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index a018abb..86ac108 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1735185254730, "tag": "0002_special_ben_parker", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1737088748932, + "tag": "0003_closed_alex_power", + "breakpoints": true } ] } \ No newline at end of file diff --git a/main.ts b/main.ts index e35c83d..d178395 100644 --- a/main.ts +++ b/main.ts @@ -58,6 +58,10 @@ object-src 'none'; base-uri 'none';" ); return next(); }); + app.use((req, res, next) => { + console.log(`${req.ip.padEnd(24)} ${req.method.padStart(8)} ${req.path}`); + return next(); + }) await init(app, db); diff --git a/routes/updates.ts b/routes/updates.ts index ef24fde..a907139 100644 --- a/routes/updates.ts +++ b/routes/updates.ts @@ -10,7 +10,14 @@ import { } from "../db/schema.js"; import { and, count, desc, eq, sql } from "drizzle-orm"; import dayjs from "dayjs"; -import { getMoods, render, render404, UserStatus } from "./util.js"; +import { + confirm, + getMoods, + journalMoodString, + render, + render404, + UserStatus +} from "./util.js"; export default async function (app: Express, db: NodePgDatabase) { const { moods, moodsSorted } = await getMoods(); @@ -155,17 +162,28 @@ export default async function (app: Express, db: NodePgDatabase) { render(db, "journal", "your journal", res, req); }); app.get("/journal/:id", async (req, res) => { + const id = parseInt(req.params.id); + if (isNaN(id)) { + req.flash("error", "Invalid ID"); + render404(db, res, req); + return; + } + const entry: { - uname: string, - title: string, - content: string, - moodChange: number, - moodString?: string, - date: Date, - visibility: number + id: number; + uid: number; + uname: string; + title: string; + content: string; + moodChange: number; + moodString?: string; + date: Date; + visibility: number; } = ( await db .select({ + id: journalEntries.id, + uid: users.id, uname: users.name, title: journalEntries.title, content: journalEntries.entry, @@ -174,45 +192,88 @@ export default async function (app: Express, db: NodePgDatabase) { visibility: journalEntries.visibility }) .from(journalEntries) - .where(eq(journalEntries.id, parseInt(req.params.id))) + .where(eq(journalEntries.id, id)) .leftJoin(users, eq(journalEntries.user, users.id)) )[0]; //? put into util function? //? also GOD - switch (entry.moodChange) { - case -2: - entry.moodString = "much worse" - break; - case -1: - entry.moodString = "worse"; - break; - default: - case 0: - entry.moodString = "about the same"; - break; - case 1: - entry.moodString = "better"; - break; - case 2: - entry.moodString = "much better"; - break; - } + entry.moodString = journalMoodString(entry.moodChange); + const isMod = req.session["status"] & UserStatus.MODERATOR; if ( !entry || - (entry.visibility === 0 && entry.uname !== req.session["user"] && !(req.session["status"] & UserStatus.MODERATOR)) + (entry.visibility === 0 && + entry.uname !== req.session["user"] && !isMod) ) { render404(db, res, req); return; } + // maybe turn ([x] !== req.session["user"] && !(req.session["status"] & UserStatus.MODERATOR)) into util function + entry.content = + entry.visibility === 2 && + entry.uid !== req.session["uid"] && + !(req.session["status"] & UserStatus.MODERATOR) + ? "This journal entry's contents have been made private." + : entry.content; + const entryTimestamp = dayjs(entry.date).fromNow(); + const isSelf = entry.uid === req.session["uid"]; render(db, "journal_view", entry.title, res, req, { entry, - entryTimestamp + entryTimestamp, + isSelf, + isMod }); }); + app.post("/journal/:id/edit", async (req, res) => { + const id = parseInt(req.params.id); + if (isNaN(id)) { + req.flash("error", "Invalid ID"); + render404(db, res, req); + return; + } + + const entry = ( + await db + .select({ + uid: journalEntries.user + }) + .from(journalEntries) + .where(eq(journalEntries.id, id)) + .limit(1) + )[0]; + + const isMod = req.session["status"] & UserStatus.MODERATOR; + if ( + !entry || + (entry?.uid !== req.session["uid"] && + !isMod) + ) { + render404(db, res, req); + return; + } + if (isMod && entry.uid !== req.session["uid"] && req.body.action !== "delete") { + req.flash("error", "Moderators can only delete other users' posts."); + res.redirect(`/journal/${req.params.id}`); + return; + } + + if (req.body.action === "edit") { + // TODO!! + } else if (req.body.action === "delete") { + if (!req.body.confirm) { + confirm(db, res, req); + return; + } + await db.delete(journalEntries).where(eq(journalEntries.id, id)); + req.flash("success", "Journal entry deleted ;w;"); + res.redirect("/"); + } else { + render404(db, res, req); + } + }); app.post("/update/journal", async (req, res) => { if (!req.session["loggedIn"]) { res.redirect("/login"); diff --git a/routes/users.ts b/routes/users.ts index e7a9413..9e5cfdc 100644 --- a/routes/users.ts +++ b/routes/users.ts @@ -7,7 +7,7 @@ import { updates, users } from "../db/schema.js"; -import { and, desc, eq } from "drizzle-orm"; +import { and, desc, eq, ne } from "drizzle-orm"; import { getMoods, render, render404, UserStatus } from "./util.js"; import { PgColumn } from "drizzle-orm/pg-core"; import dayjs from "dayjs"; @@ -79,10 +79,15 @@ export default async function (app: Express, db: NodePgDatabase) { .select({ id: journalEntries.id, title: journalEntries.title, - date: journalEntries.date + date: journalEntries.date, + visibility: journalEntries.visibility }) .from(journalEntries) - .where(eq(journalEntries.user, user.id)) + .where( + user.id === req.session["uid"] || req.session["status"] & UserStatus.MODERATOR + ? eq(journalEntries.user, user.id) + : and(eq(journalEntries.user, user.id), ne(journalEntries.visibility, 0)) + ) .orderBy(desc(journalEntries.date)) .limit(5) ).map((e) => { @@ -90,6 +95,7 @@ export default async function (app: Express, db: NodePgDatabase) { id: e.id, title: e.title, date: e.date, + visibility: e.visibility, relativeDate: now.to(e.date) }; }); @@ -115,7 +121,7 @@ export default async function (app: Express, db: NodePgDatabase) { }; }); - if (!isSelf) { + if (!isSelf && userMood) { userMood.mood = moods[userMood.mood as number]; } diff --git a/routes/util.ts b/routes/util.ts index 16c2432..addf4ef 100644 --- a/routes/util.ts +++ b/routes/util.ts @@ -115,3 +115,27 @@ export async function createInviteCode( }); return token; } + +export function journalMoodString(mood: number) { + switch (mood) { + case -2: + return "much worse"; + case -1: + return "worse"; + default: + case 0: + return "about the same"; + case 1: + return "better"; + case 2: + return "much better"; + } +} + +export function confirm( + db: NodePgDatabase, + res: Response, + req: Request +) { + render(db, "confirm", "Confirm action", res, req, { body: req.body, url: req.url }); +} diff --git a/static/css/main.css b/static/css/main.css index 5f1a3c1..6862e02 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -166,6 +166,7 @@ input { background-color: white; padding: 4px; font-family: inherit; + font-size: 14px; } button:focus, textarea:focus, diff --git a/views/confirm.pug b/views/confirm.pug new file mode 100644 index 0000000..93cda3a --- /dev/null +++ b/views/confirm.pug @@ -0,0 +1,9 @@ +extends site.pug + +block content + form(action=url, method="post") + for [k, v] of Object.entries(body) + input(type="text", name=k, value=v, hidden) + p Sure you want to do this?? + button(name="confirm", value="y") Yes + button(onclick="history.back();") NO!!!! diff --git a/views/journal_view.pug b/views/journal_view.pug index e9099cf..9028681 100644 --- a/views/journal_view.pug +++ b/views/journal_view.pug @@ -1,7 +1,14 @@ extends site.pug block content - h1= entry.title + form(action=`/journal/${entry.id}/edit`, method="post") + h1(style="display:flex;align-items:center;gap:1em;") + | #{entry.title} + if isSelf || isMod + div(style="display:flex;") + if isSelf + button(name="action", value="edit") edit + button(name="action", value="delete") delete span | by a(href=`/users/${entry.uname}`)= entry.uname diff --git a/views/user.pug b/views/user.pug index 4b2d390..622b647 100644 --- a/views/user.pug +++ b/views/user.pug @@ -34,7 +34,15 @@ block content for entry of userJournalEntries li a(href=`/journal/${entry.id}`)= entry.title - span.subtle(title=entry.date.toLocaleString()) (#{entry.relativeDate}) + span.subtle(title=entry.date.toLocaleString()) + | (#{entry.relativeDate} + if entry.visibility !== 1 + | , + if entry.visibility === 0 + | private + if entry.visibility === 2 + | mood-only + | ) else div [no entries] br