From 1648c551d16fcbae96b4c53bd2c0d41aa21459c1 Mon Sep 17 00:00:00 2001
From: DIodide
Date: Mon, 28 Sep 2026 04:43:18 -0400
Subject: [PATCH 01/12] chore: bump inbox-engine submodule (announcement
signals)
---
packages/inbox-engine | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/inbox-engine b/packages/inbox-engine
index 4987399..31501ea 160000
--- a/packages/inbox-engine
+++ b/packages/inbox-engine
@@ -1 +1 @@
-Subproject commit 4987399233053644c689a800911daa9fcd132b22
+Subproject commit 31501ead277d039d0abfd7f69e55daf4b4d4f741
From 12b6399e598bbe9fba75428d68695682fa728201 Mon Sep 17 00:00:00 2001
From: DIodide
Date: Mon, 28 Sep 2026 04:44:14 -0400
Subject: [PATCH 02/12] db: announcement signals on events and org_blocks
table; sync announcements from InboxEngine
---
apps/database/drizzle/0006_feed_controls.sql | 12 +
apps/database/drizzle/meta/0006_snapshot.json | 2027 +++++++++++++++++
apps/database/drizzle/meta/_journal.json | 7 +
apps/database/src/schema/index.ts | 26 +
apps/database/src/sync-inbox-engine.ts | 16 +-
5 files changed, 2087 insertions(+), 1 deletion(-)
create mode 100644 apps/database/drizzle/0006_feed_controls.sql
create mode 100644 apps/database/drizzle/meta/0006_snapshot.json
diff --git a/apps/database/drizzle/0006_feed_controls.sql b/apps/database/drizzle/0006_feed_controls.sql
new file mode 100644
index 0000000..d842962
--- /dev/null
+++ b/apps/database/drizzle/0006_feed_controls.sql
@@ -0,0 +1,12 @@
+CREATE TABLE "org_blocks" (
+ "user_id" uuid NOT NULL,
+ "org_id" uuid NOT NULL,
+ "created_at" timestamp DEFAULT now() NOT NULL,
+ CONSTRAINT "org_blocks_user_id_org_id_pk" PRIMARY KEY("user_id","org_id")
+);
+--> statement-breakpoint
+ALTER TABLE "events" ADD COLUMN "announcement_count" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
+ALTER TABLE "events" ADD COLUMN "announced_at" timestamp;--> statement-breakpoint
+ALTER TABLE "org_blocks" ADD CONSTRAINT "org_blocks_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "org_blocks" ADD CONSTRAINT "org_blocks_org_id_organizations_id_fk" FOREIGN KEY ("org_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+CREATE INDEX "org_blocks_user_id_idx" ON "org_blocks" USING btree ("user_id");
\ No newline at end of file
diff --git a/apps/database/drizzle/meta/0006_snapshot.json b/apps/database/drizzle/meta/0006_snapshot.json
new file mode 100644
index 0000000..d8362b6
--- /dev/null
+++ b/apps/database/drizzle/meta/0006_snapshot.json
@@ -0,0 +1,2027 @@
+{
+ "id": "2cd6094f-33e0-4aa1-a700-002ee53f3861",
+ "prevId": "9345e621-b354-4574-95a7-965d40fd27c7",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.campus_locations": {
+ "name": "campus_locations",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(100)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "latitude": {
+ "name": "latitude",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "longitude": {
+ "name": "longitude",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "category": {
+ "name": "category",
+ "type": "location_category",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.event_tag_embeddings": {
+ "name": "event_tag_embeddings",
+ "schema": "",
+ "columns": {
+ "tag_name": {
+ "name": "tag_name",
+ "type": "event_tag",
+ "typeSchema": "public",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "embedding": {
+ "name": "embedding",
+ "type": "vector(1536)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.event_tags": {
+ "name": "event_tags",
+ "schema": "",
+ "columns": {
+ "event_id": {
+ "name": "event_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tag": {
+ "name": "tag",
+ "type": "event_tag",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "event_tags_tag_idx": {
+ "name": "event_tags_tag_idx",
+ "columns": [
+ {
+ "expression": "tag",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "event_tags_event_id_events_id_fk": {
+ "name": "event_tags_event_id_events_id_fk",
+ "tableFrom": "event_tags",
+ "tableTo": "events",
+ "columnsFrom": [
+ "event_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "event_tags_event_id_tag_pk": {
+ "name": "event_tags_event_id_tag_pk",
+ "columns": [
+ "event_id",
+ "tag"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.events": {
+ "name": "events",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "datetime": {
+ "name": "datetime",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "end_datetime": {
+ "name": "end_datetime",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "location_id": {
+ "name": "location_id",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "flyer_url": {
+ "name": "flyer_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cover_preset": {
+ "name": "cover_preset",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "external_link": {
+ "name": "external_link",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_public": {
+ "name": "is_public",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "status": {
+ "name": "status",
+ "type": "event_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'published'"
+ },
+ "source": {
+ "name": "source",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'manual'"
+ },
+ "source_message_id": {
+ "name": "source_message_id",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source_url": {
+ "name": "source_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "location_detail": {
+ "name": "location_detail",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "announcement_count": {
+ "name": "announcement_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "announced_at": {
+ "name": "announced_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "events_published_datetime_idx": {
+ "name": "events_published_datetime_idx",
+ "columns": [
+ {
+ "expression": "datetime",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"events\".\"status\" = 'published'",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "events_org_id_idx": {
+ "name": "events_org_id_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "events_creator_id_idx": {
+ "name": "events_creator_id_idx",
+ "columns": [
+ {
+ "expression": "creator_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "events_location_id_idx": {
+ "name": "events_location_id_idx",
+ "columns": [
+ {
+ "expression": "location_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "events_location_id_campus_locations_id_fk": {
+ "name": "events_location_id_campus_locations_id_fk",
+ "tableFrom": "events",
+ "tableTo": "campus_locations",
+ "columnsFrom": [
+ "location_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "events_org_id_organizations_id_fk": {
+ "name": "events_org_id_organizations_id_fk",
+ "tableFrom": "events",
+ "tableTo": "organizations",
+ "columnsFrom": [
+ "org_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "events_creator_id_users_id_fk": {
+ "name": "events_creator_id_users_id_fk",
+ "tableFrom": "events",
+ "tableTo": "users",
+ "columnsFrom": [
+ "creator_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "events_source_message_id_unique": {
+ "name": "events_source_message_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "source_message_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.friendships": {
+ "name": "friendships",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "friend_id": {
+ "name": "friend_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "friendship_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "friendships_friend_id_status_idx": {
+ "name": "friendships_friend_id_status_idx",
+ "columns": [
+ {
+ "expression": "friend_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "friendships_user_id_users_id_fk": {
+ "name": "friendships_user_id_users_id_fk",
+ "tableFrom": "friendships",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "friendships_friend_id_users_id_fk": {
+ "name": "friendships_friend_id_users_id_fk",
+ "tableFrom": "friendships",
+ "tableTo": "users",
+ "columnsFrom": [
+ "friend_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "friendships_user_id_friend_id_pk": {
+ "name": "friendships_user_id_friend_id_pk",
+ "columns": [
+ "user_id",
+ "friend_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.interactions": {
+ "name": "interactions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "item_id": {
+ "name": "item_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "item_type": {
+ "name": "item_type",
+ "type": "item_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'event'"
+ },
+ "interaction_type": {
+ "name": "interaction_type",
+ "type": "interaction_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "interaction_value": {
+ "name": "interaction_value",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "interactions_user_id_created_at_idx": {
+ "name": "interactions_user_id_created_at_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "interactions_item_id_type_idx": {
+ "name": "interactions_item_id_type_idx",
+ "columns": [
+ {
+ "expression": "item_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "interaction_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "interactions_user_id_users_id_fk": {
+ "name": "interactions_user_id_users_id_fk",
+ "tableFrom": "interactions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.listserv_configs": {
+ "name": "listserv_configs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "address": {
+ "name": "address",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "label": {
+ "name": "label",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "gmail_label": {
+ "name": "gmail_label",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "listserv_configs_org_id_organizations_id_fk": {
+ "name": "listserv_configs_org_id_organizations_id_fk",
+ "tableFrom": "listserv_configs",
+ "tableTo": "organizations",
+ "columnsFrom": [
+ "org_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "listserv_configs_address_unique": {
+ "name": "listserv_configs_address_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "address"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.listserv_emails": {
+ "name": "listserv_emails",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "message_id": {
+ "name": "message_id",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "listserv": {
+ "name": "listserv",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "subject": {
+ "name": "subject",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "author_name": {
+ "name": "author_name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "author_email": {
+ "name": "author_email",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "date": {
+ "name": "date",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "body_text": {
+ "name": "body_text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "body_html": {
+ "name": "body_html",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_hoagiemail": {
+ "name": "is_hoagiemail",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "hoagiemail_sender_name": {
+ "name": "hoagiemail_sender_name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "hoagiemail_sender_email": {
+ "name": "hoagiemail_sender_email",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "links": {
+ "name": "links",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'[]'::jsonb"
+ },
+ "images": {
+ "name": "images",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'[]'::jsonb"
+ },
+ "attachments": {
+ "name": "attachments",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'[]'::jsonb"
+ },
+ "listserv_url": {
+ "name": "listserv_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "listserv_emails_message_id_unique": {
+ "name": "listserv_emails_message_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "message_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.notifications": {
+ "name": "notifications",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "notification_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "payload": {
+ "name": "payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "read": {
+ "name": "read",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "notifications_user_id_created_at_idx": {
+ "name": "notifications_user_id_created_at_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "notifications_user_id_users_id_fk": {
+ "name": "notifications_user_id_users_id_fk",
+ "tableFrom": "notifications",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.org_blocks": {
+ "name": "org_blocks",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "org_blocks_user_id_idx": {
+ "name": "org_blocks_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "org_blocks_user_id_users_id_fk": {
+ "name": "org_blocks_user_id_users_id_fk",
+ "tableFrom": "org_blocks",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "org_blocks_org_id_organizations_id_fk": {
+ "name": "org_blocks_org_id_organizations_id_fk",
+ "tableFrom": "org_blocks",
+ "tableTo": "organizations",
+ "columnsFrom": [
+ "org_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "org_blocks_user_id_org_id_pk": {
+ "name": "org_blocks_user_id_org_id_pk",
+ "columns": [
+ "user_id",
+ "org_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.org_followers": {
+ "name": "org_followers",
+ "schema": "",
+ "columns": {
+ "org_id": {
+ "name": "org_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "org_followers_user_id_idx": {
+ "name": "org_followers_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "org_followers_org_id_organizations_id_fk": {
+ "name": "org_followers_org_id_organizations_id_fk",
+ "tableFrom": "org_followers",
+ "tableTo": "organizations",
+ "columnsFrom": [
+ "org_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "org_followers_user_id_users_id_fk": {
+ "name": "org_followers_user_id_users_id_fk",
+ "tableFrom": "org_followers",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "org_followers_org_id_user_id_pk": {
+ "name": "org_followers_org_id_user_id_pk",
+ "columns": [
+ "org_id",
+ "user_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.org_members": {
+ "name": "org_members",
+ "schema": "",
+ "columns": {
+ "org_id": {
+ "name": "org_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "org_role",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "org_members_user_id_idx": {
+ "name": "org_members_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "org_members_org_id_organizations_id_fk": {
+ "name": "org_members_org_id_organizations_id_fk",
+ "tableFrom": "org_members",
+ "tableTo": "organizations",
+ "columnsFrom": [
+ "org_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "org_members_user_id_users_id_fk": {
+ "name": "org_members_user_id_users_id_fk",
+ "tableFrom": "org_members",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "org_members_org_id_user_id_pk": {
+ "name": "org_members_org_id_user_id_pk",
+ "columns": [
+ "org_id",
+ "user_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.organizations": {
+ "name": "organizations",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "logo_url": {
+ "name": "logo_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "category": {
+ "name": "category",
+ "type": "org_category",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source": {
+ "name": "source",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'manual'"
+ },
+ "external_id": {
+ "name": "external_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "acronym": {
+ "name": "acronym",
+ "type": "varchar(40)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tagline": {
+ "name": "tagline",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "group_type": {
+ "name": "group_type",
+ "type": "varchar(120)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "group_url": {
+ "name": "group_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "website": {
+ "name": "website",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "contact_email": {
+ "name": "contact_email",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "socials": {
+ "name": "socials",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "member_count": {
+ "name": "member_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "synced_at": {
+ "name": "synced_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "organizations_creator_id_users_id_fk": {
+ "name": "organizations_creator_id_users_id_fk",
+ "tableFrom": "organizations",
+ "tableTo": "users",
+ "columnsFrom": [
+ "creator_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "organizations_name_unique": {
+ "name": "organizations_name_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "name"
+ ]
+ },
+ "organizations_external_id_unique": {
+ "name": "organizations_external_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "external_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.pipeline_logs": {
+ "name": "pipeline_logs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "message_id": {
+ "name": "message_id",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "listserv_config_id": {
+ "name": "listserv_config_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "pipeline_log_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "error_text": {
+ "name": "error_text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "extracted_event_id": {
+ "name": "extracted_event_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "pipeline_logs_listserv_config_id_listserv_configs_id_fk": {
+ "name": "pipeline_logs_listserv_config_id_listserv_configs_id_fk",
+ "tableFrom": "pipeline_logs",
+ "tableTo": "listserv_configs",
+ "columnsFrom": [
+ "listserv_config_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "pipeline_logs_extracted_event_id_events_id_fk": {
+ "name": "pipeline_logs_extracted_event_id_events_id_fk",
+ "tableFrom": "pipeline_logs",
+ "tableTo": "events",
+ "columnsFrom": [
+ "extracted_event_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.rsvps": {
+ "name": "rsvps",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "event_id": {
+ "name": "event_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "rsvps_event_id_idx": {
+ "name": "rsvps_event_id_idx",
+ "columns": [
+ {
+ "expression": "event_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "rsvps_user_id_users_id_fk": {
+ "name": "rsvps_user_id_users_id_fk",
+ "tableFrom": "rsvps",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "rsvps_event_id_events_id_fk": {
+ "name": "rsvps_event_id_events_id_fk",
+ "tableFrom": "rsvps",
+ "tableTo": "events",
+ "columnsFrom": [
+ "event_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "rsvps_user_id_event_id_pk": {
+ "name": "rsvps_user_id_event_id_pk",
+ "columns": [
+ "user_id",
+ "event_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.saved_events": {
+ "name": "saved_events",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "event_id": {
+ "name": "event_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "saved_events_event_id_idx": {
+ "name": "saved_events_event_id_idx",
+ "columns": [
+ {
+ "expression": "event_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "saved_events_user_id_users_id_fk": {
+ "name": "saved_events_user_id_users_id_fk",
+ "tableFrom": "saved_events",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "saved_events_event_id_events_id_fk": {
+ "name": "saved_events_event_id_events_id_fk",
+ "tableFrom": "saved_events",
+ "tableTo": "events",
+ "columnsFrom": [
+ "event_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "saved_events_user_id_event_id_pk": {
+ "name": "saved_events_user_id_event_id_pk",
+ "columns": [
+ "user_id",
+ "event_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sync_state": {
+ "name": "sync_state",
+ "schema": "",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "varchar(100)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_interests": {
+ "name": "user_interests",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tag": {
+ "name": "tag",
+ "type": "event_tag",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "user_interests_user_id_users_id_fk": {
+ "name": "user_interests_user_id_users_id_fk",
+ "tableFrom": "user_interests",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "user_interests_user_id_tag_pk": {
+ "name": "user_interests_user_id_tag_pk",
+ "columns": [
+ "user_id",
+ "tag"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_preference_vectors": {
+ "name": "user_preference_vectors",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "tag_weights": {
+ "name": "tag_weights",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "latent_vector": {
+ "name": "latent_vector",
+ "type": "double precision[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "interaction_count": {
+ "name": "interaction_count",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "user_preference_vectors_user_id_users_id_fk": {
+ "name": "user_preference_vectors_user_id_users_id_fk",
+ "tableFrom": "user_preference_vectors",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_regions": {
+ "name": "user_regions",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "region": {
+ "name": "region",
+ "type": "campus_region",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "user_regions_user_id_users_id_fk": {
+ "name": "user_regions_user_id_users_id_fk",
+ "tableFrom": "user_regions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "user_regions_user_id_region_pk": {
+ "name": "user_regions_user_id_region_pk",
+ "columns": [
+ "user_id",
+ "region"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.users": {
+ "name": "users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "net_id": {
+ "name": "net_id",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "display_name": {
+ "name": "display_name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "class_year": {
+ "name": "class_year",
+ "type": "varchar(10)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "major": {
+ "name": "major",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "avatar_url": {
+ "name": "avatar_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_org_leader": {
+ "name": "is_org_leader",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "onboarded": {
+ "name": "onboarded",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "calendar_token": {
+ "name": "calendar_token",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "users_net_id_unique": {
+ "name": "users_net_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "net_id"
+ ]
+ },
+ "users_email_unique": {
+ "name": "users_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ },
+ "users_calendar_token_unique": {
+ "name": "users_calendar_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "calendar_token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.campus_region": {
+ "name": "campus_region",
+ "schema": "public",
+ "values": [
+ "central",
+ "east",
+ "west",
+ "south",
+ "north",
+ "off-campus"
+ ]
+ },
+ "public.event_status": {
+ "name": "event_status",
+ "schema": "public",
+ "values": [
+ "draft",
+ "published"
+ ]
+ },
+ "public.event_tag": {
+ "name": "event_tag",
+ "schema": "public",
+ "values": [
+ "free food",
+ "career",
+ "research",
+ "academics",
+ "tech",
+ "entrepreneurship",
+ "politics",
+ "visual arts",
+ "performing arts",
+ "literature",
+ "culture",
+ "music",
+ "gaming",
+ "athletics",
+ "religion",
+ "sustainability",
+ "outdoors",
+ "wellness",
+ "community service",
+ "speaker event",
+ "social event",
+ "stem"
+ ]
+ },
+ "public.friendship_status": {
+ "name": "friendship_status",
+ "schema": "public",
+ "values": [
+ "pending",
+ "accepted",
+ "declined"
+ ]
+ },
+ "public.interaction_type": {
+ "name": "interaction_type",
+ "schema": "public",
+ "values": [
+ "view",
+ "click",
+ "rsvp",
+ "save",
+ "share",
+ "hide"
+ ]
+ },
+ "public.item_type": {
+ "name": "item_type",
+ "schema": "public",
+ "values": [
+ "event",
+ "organization"
+ ]
+ },
+ "public.location_category": {
+ "name": "location_category",
+ "schema": "public",
+ "values": [
+ "academic",
+ "residential",
+ "athletic",
+ "social",
+ "administrative",
+ "library",
+ "dining",
+ "other"
+ ]
+ },
+ "public.notification_type": {
+ "name": "notification_type",
+ "schema": "public",
+ "values": [
+ "friend_request",
+ "event_reminder",
+ "org_new_event"
+ ]
+ },
+ "public.org_category": {
+ "name": "org_category",
+ "schema": "public",
+ "values": [
+ "career",
+ "affinity",
+ "performing arts",
+ "academics",
+ "athletics",
+ "social event",
+ "culture",
+ "religion",
+ "politics",
+ "community service"
+ ]
+ },
+ "public.org_role": {
+ "name": "org_role",
+ "schema": "public",
+ "values": [
+ "owner",
+ "officer",
+ "member"
+ ]
+ },
+ "public.pipeline_log_status": {
+ "name": "pipeline_log_status",
+ "schema": "public",
+ "values": [
+ "success",
+ "skipped_not_event",
+ "duplicate",
+ "error",
+ "needs_review"
+ ]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/apps/database/drizzle/meta/_journal.json b/apps/database/drizzle/meta/_journal.json
index af25693..9fc4a1a 100644
--- a/apps/database/drizzle/meta/_journal.json
+++ b/apps/database/drizzle/meta/_journal.json
@@ -43,6 +43,13 @@
"when": 1790583252834,
"tag": "0005_user_calendar_token",
"breakpoints": true
+ },
+ {
+ "idx": 6,
+ "version": "7",
+ "when": 1790585035452,
+ "tag": "0006_feed_controls",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/apps/database/src/schema/index.ts b/apps/database/src/schema/index.ts
index 69ab2fd..e3d606a 100644
--- a/apps/database/src/schema/index.ts
+++ b/apps/database/src/schema/index.ts
@@ -214,6 +214,24 @@ export const orgFollowers = pgTable(
],
);
+/** Orgs a user has hidden: their events are removed from every discovery surface. */
+export const orgBlocks = pgTable(
+ "org_blocks",
+ {
+ userId: uuid("user_id")
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+ orgId: uuid("org_id")
+ .notNull()
+ .references(() => organizations.id, { onDelete: "cascade" }),
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ },
+ (t) => [
+ primaryKey({ columns: [t.userId, t.orgId] }),
+ index("org_blocks_user_id_idx").on(t.userId),
+ ],
+);
+
export const events = pgTable(
"events",
{
@@ -244,6 +262,14 @@ export const events = pgTable(
sourceUrl: text("source_url"),
/** Room or free-text place beyond the campus location ("Room 104", "Zoom"). */
locationDetail: varchar("location_detail", { length: 200 }),
+ /**
+ * How many campus listserv emails announced this event (its own source
+ * email plus folded-in reminders), from InboxEngine. 0 for unannounced
+ * MyPrincetonU listings and Forum-created events. A ranking signal.
+ */
+ announcementCount: integer("announcement_count").default(0).notNull(),
+ /** When the event was first announced on a listserv, if ever ("Recently posted" sort). */
+ announcedAt: timestamp("announced_at"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
},
diff --git a/apps/database/src/sync-inbox-engine.ts b/apps/database/src/sync-inbox-engine.ts
index 8b10f65..a104bd5 100644
--- a/apps/database/src/sync-inbox-engine.ts
+++ b/apps/database/src/sync-inbox-engine.ts
@@ -229,16 +229,28 @@ export async function syncEvents(client = engineClient()) {
status: events.status,
isPublic: events.isPublic,
updatedAt: events.updatedAt,
+ announcementCount: events.announcementCount,
+ announcedAt: events.announcedAt,
})
.from(events)
.where(eq(events.sourceMessageId, key));
const publish = shouldPublish(e, seriesRank);
const isPublished = existing?.status === "published" && existing.isPublic;
+ const announcementCount = e.announcements.emails;
+ const announcedAt = e.announcements.firstAnnouncedAt
+ ? new Date(e.announcements.firstAnnouncedAt)
+ : null;
+ // InboxEngine bumps updatedAt when announcements are folded in; comparing
+ // the stored signals too backfills rows written before these columns existed.
+ const announcementsCurrent =
+ existing?.announcementCount === announcementCount &&
+ (existing.announcedAt?.getTime() ?? null) === (announcedAt?.getTime() ?? null);
// Unchanged upstream and already in the desired state: nothing to write.
if (
existing &&
publish === isPublished &&
- existing.updatedAt.getTime() >= new Date(e.updatedAt).getTime()
+ existing.updatedAt.getTime() >= new Date(e.updatedAt).getTime() &&
+ (!publish || announcementsCurrent)
) {
stats.skipped++;
continue;
@@ -278,6 +290,8 @@ export async function syncEvents(client = engineClient()) {
status: "published" as const,
source: e.source.kind,
sourceMessageId: key,
+ announcementCount,
+ announcedAt,
updatedAt: new Date(),
};
let eventId = existing?.id;
From a906a4c35752faf619b78e104a5a866eb584f571 Mon Sep 17 00:00:00 2001
From: DIodide
Date: Mon, 28 Sep 2026 04:54:17 -0400
Subject: [PATCH 03/12] orgs: hide (block) organizations from discovery
blockOrg / unblockOrg / getBlockedOrgs server actions (zod-validated,
rate-limited, idempotent). Hiding unfollows and following unhides, each in
one transaction. notFromHiddenOrg() filters hidden orgs from the map and org
suggestions; getOrg/getOrgs expose isHidden.
---
apps/database/src/index.ts | 1 +
apps/web/src/actions/map.ts | 4 +-
apps/web/src/actions/orgs.ts | 140 ++++++++++++++++++++++++---
apps/web/src/lib/event-visibility.ts | 48 ++++++++-
apps/web/src/lib/org-preferences.ts | 62 ++++++++++++
apps/web/src/lib/rate-limit.ts | 2 +
6 files changed, 243 insertions(+), 14 deletions(-)
create mode 100644 apps/web/src/lib/org-preferences.ts
diff --git a/apps/database/src/index.ts b/apps/database/src/index.ts
index 82da124..72b5fa6 100644
--- a/apps/database/src/index.ts
+++ b/apps/database/src/index.ts
@@ -9,6 +9,7 @@ export {
asc,
sql,
inArray,
+ notInArray,
like,
ilike,
count,
diff --git a/apps/web/src/actions/map.ts b/apps/web/src/actions/map.ts
index 040cf97..2d904fe 100644
--- a/apps/web/src/actions/map.ts
+++ b/apps/web/src/actions/map.ts
@@ -19,7 +19,7 @@ import {
import { z } from "zod";
import { auth } from "~/auth";
import { formatTime } from "~/lib/date-format";
-import { eventDiscoverableBy } from "~/lib/event-visibility";
+import { eventDiscoverableBy, notFromHiddenOrg } from "~/lib/event-visibility";
import { loadFriendIds } from "~/lib/social-graph";
import { dateInputSchema, parseInput } from "~/lib/validation";
@@ -85,6 +85,8 @@ export async function getMapEvents(opts?: {
lt(events.datetime, endDate),
// Published AND visible to this viewer — see ~/lib/event-visibility.ts.
eventDiscoverableBy(userId),
+ // Not from an org the viewer hid.
+ notFromHiddenOrg(userId),
// The "other" placeholder location sits at (0, 0) — off the coast of
// Africa. Events there have no real coordinates, so keep them off the map.
or(ne(campusLocations.latitude, 0), ne(campusLocations.longitude, 0)),
diff --git a/apps/web/src/actions/orgs.ts b/apps/web/src/actions/orgs.ts
index e5b7396..d0bcbd3 100644
--- a/apps/web/src/actions/orgs.ts
+++ b/apps/web/src/actions/orgs.ts
@@ -11,6 +11,7 @@ import {
ilike,
inArray,
or,
+ orgBlocks,
orgFollowers,
orgMembers,
organizations,
@@ -23,6 +24,7 @@ import { z } from "zod";
import { auth } from "~/auth";
import { formatEventDateTime } from "~/lib/date-format";
import { eventVisibleTo } from "~/lib/event-visibility";
+import { clearFollowing, clearHidden, setFollowing, setHidden } from "~/lib/org-preferences";
import { enforceRateLimit } from "~/lib/rate-limit";
import { uploadedImageUrlSchema } from "~/lib/s3";
import { loadFriendIds } from "~/lib/social-graph";
@@ -37,6 +39,8 @@ export interface OrgListItem {
category: string;
followerCount: number;
isFollowing: boolean;
+ /** The viewer hid this org's events from their feed. */
+ isHidden: boolean;
}
export interface OrgPerson {
@@ -68,6 +72,8 @@ export interface OrgDetail {
memberCount: number | null;
followerCount: number;
isFollowing: boolean;
+ /** The viewer hid this org's events from their feed (its page still lists them). */
+ isHidden: boolean;
isOwner: boolean;
/** The viewer's friends who follow this org (social proof; never the full follower list). */
friendsFollowing: OrgPerson[];
@@ -130,7 +136,7 @@ export async function getOrgs(params?: {
// Follower counts + the viewer's follow state: two grouped queries for the
// whole list, instead of two queries per org.
const orgIds = orgs.map((o) => o.id);
- const [countRows, myFollows] = await Promise.all([
+ const [countRows, myFollows, myHidden] = await Promise.all([
db
.select({ orgId: orgFollowers.orgId, count: sql`count(*)::int` })
.from(orgFollowers)
@@ -140,14 +146,17 @@ export async function getOrgs(params?: {
.select({ orgId: orgFollowers.orgId })
.from(orgFollowers)
.where(and(eq(orgFollowers.userId, userId), inArray(orgFollowers.orgId, orgIds))),
+ db.select({ orgId: orgBlocks.orgId }).from(orgBlocks).where(eq(orgBlocks.userId, userId)),
]);
const countByOrg = new Map(countRows.map((r) => [r.orgId, r.count]));
const followed = new Set(myFollows.map((r) => r.orgId));
+ const hidden = new Set(myHidden.map((r) => r.orgId));
return orgs.map((org) => ({
...org,
followerCount: countByOrg.get(org.id) ?? 0,
isFollowing: followed.has(org.id),
+ isHidden: hidden.has(org.id),
}));
}
@@ -165,7 +174,7 @@ export async function getOrg(orgId: string): Promise {
if (!org) return null;
const friendIds = await loadFriendIds(userId);
- const [[countResult], [following], friendsFollowing, team] = await Promise.all([
+ const [[countResult], [following], [hidden], friendsFollowing, team] = await Promise.all([
db
.select({ count: sql`count(*)::int` })
.from(orgFollowers)
@@ -175,6 +184,11 @@ export async function getOrg(orgId: string): Promise {
.from(orgFollowers)
.where(and(eq(orgFollowers.orgId, orgId), eq(orgFollowers.userId, userId)))
.limit(1),
+ db
+ .select({ userId: orgBlocks.userId })
+ .from(orgBlocks)
+ .where(and(eq(orgBlocks.orgId, orgId), eq(orgBlocks.userId, userId)))
+ .limit(1),
friendIds.length === 0
? Promise.resolve([] as OrgPerson[])
: db
@@ -272,6 +286,7 @@ export async function getOrg(orgId: string): Promise {
memberCount: org.memberCount,
followerCount: countResult?.count ?? 0,
isFollowing: !!following,
+ isHidden: !!hidden,
isOwner: !!org.creatorId && org.creatorId === userId,
friendsFollowing,
team,
@@ -335,12 +350,18 @@ export async function createOrg(data: {
return { id: orgId };
}
+/**
+ * Follow or unfollow. Following hoists the org's events in the feed and
+ * clears a hide (the two are mutually exclusive — see ~/lib/org-preferences).
+ */
export async function toggleFollowOrg(orgId: string): Promise<{ following: boolean }> {
const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized");
const userId = session.user.id;
const id = parseInput(idSchema, orgId);
+ enforceRateLimit("orgPreference", userId);
+ await assertOrgExists(id);
const [existing] = await db
.select({ orgId: orgFollowers.orgId })
@@ -349,13 +370,8 @@ export async function toggleFollowOrg(orgId: string): Promise<{ following: boole
.limit(1);
// Idempotent either way, so a double-click can't throw on the primary key.
- if (existing) {
- await db
- .delete(orgFollowers)
- .where(and(eq(orgFollowers.orgId, id), eq(orgFollowers.userId, userId)));
- } else {
- await db.insert(orgFollowers).values({ orgId: id, userId }).onConflictDoNothing();
- }
+ if (existing) await clearFollowing(userId, id);
+ else await setFollowing(userId, id);
revalidatePath(`/orgs/${id}`);
revalidatePath("/orgs");
@@ -363,6 +379,99 @@ export async function toggleFollowOrg(orgId: string): Promise<{ following: boole
return { following: !existing };
}
+async function assertOrgExists(orgId: string) {
+ const [org] = await db
+ .select({ id: organizations.id })
+ .from(organizations)
+ .where(eq(organizations.id, orgId))
+ .limit(1);
+ if (!org) throw new Error("Organization not found");
+}
+
+/**
+ * Hide an org's events from discovery — Explore (every sort and search), the
+ * map, similar events and friends' activity. Its own page and direct event
+ * links still work. Hiding unfollows; `unfollowed` tells the caller whether
+ * Undo should restore the follow. Idempotent.
+ */
+export async function blockOrg(orgId: string): Promise<{ hidden: true; unfollowed: boolean }> {
+ const session = await auth();
+ if (!session?.user?.id) throw new Error("Unauthorized");
+
+ const userId = session.user.id;
+ const id = parseInput(idSchema, orgId);
+ enforceRateLimit("orgPreference", userId);
+ await assertOrgExists(id);
+
+ const { unfollowed } = await setHidden(userId, id);
+
+ revalidatePath(`/orgs/${id}`);
+ revalidatePath("/orgs");
+
+ return { hidden: true, unfollowed };
+}
+
+const unblockOrgSchema = z.object({
+ orgId: idSchema,
+ /** Undo of a hide that unfollowed: follow again. */
+ refollow: z.boolean().default(false),
+});
+
+/** Show a hidden org's events again (optionally re-following it, for Undo). Idempotent. */
+export async function unblockOrg(
+ orgId: string,
+ opts?: { refollow?: boolean },
+): Promise<{ hidden: false; following: boolean }> {
+ const session = await auth();
+ if (!session?.user?.id) throw new Error("Unauthorized");
+
+ const userId = session.user.id;
+ const input = parseInput(unblockOrgSchema, { orgId, refollow: opts?.refollow });
+ enforceRateLimit("orgPreference", userId);
+
+ if (input.refollow) {
+ await assertOrgExists(input.orgId);
+ await setFollowing(userId, input.orgId);
+ } else {
+ await clearHidden(userId, input.orgId);
+ }
+
+ revalidatePath(`/orgs/${input.orgId}`);
+ revalidatePath("/orgs");
+
+ return { hidden: false, following: input.refollow };
+}
+
+export interface HiddenOrg {
+ id: string;
+ name: string;
+ logoUrl: string | null;
+ category: string;
+ /** ISO timestamp the viewer hid it. */
+ hiddenAt: string;
+}
+
+/** Orgs the viewer has hidden, most recently hidden first. */
+export async function getBlockedOrgs(): Promise {
+ const session = await auth();
+ if (!session?.user?.id) throw new Error("Unauthorized");
+
+ const rows = await db
+ .select({
+ id: organizations.id,
+ name: organizations.name,
+ logoUrl: organizations.logoUrl,
+ category: organizations.category,
+ hiddenAt: orgBlocks.createdAt,
+ })
+ .from(orgBlocks)
+ .innerJoin(organizations, eq(orgBlocks.orgId, organizations.id))
+ .where(eq(orgBlocks.userId, session.user.id))
+ .orderBy(desc(orgBlocks.createdAt), organizations.name);
+
+ return rows.map((r) => ({ ...r, hiddenAt: r.hiddenAt.toISOString() }));
+}
+
const officerSchema = z.object({ orgId: idSchema, userId: idSchema });
async function assertOrgCreator(orgId: string, callerId: string, message: string) {
@@ -467,7 +576,13 @@ export async function getRecommendedOrgs(): Promise {
.from(orgFollowers)
.where(eq(orgFollowers.userId, userId));
- const followedOrgIds = followedOrgs.map((f) => f.orgId);
+ // Hidden orgs are never suggested either.
+ const hiddenOrgs = await db
+ .select({ orgId: orgBlocks.orgId })
+ .from(orgBlocks)
+ .where(eq(orgBlocks.userId, userId));
+
+ const excludedOrgIds = [...followedOrgs, ...hiddenOrgs].map((f) => f.orgId);
// Find orgs whose events have matching tags, ranked by overlap count
const recommended = await db
@@ -488,9 +603,9 @@ export async function getRecommendedOrgs(): Promise {
// Only publicly listed events count as evidence of what an org hosts.
eq(events.status, "published"),
eq(events.isPublic, true),
- followedOrgIds.length > 0
+ excludedOrgIds.length > 0
? sql`${organizations.id} NOT IN (${sql.join(
- followedOrgIds.map((id) => sql`${id}`),
+ excludedOrgIds.map((id) => sql`${id}`),
sql`, `,
)})`
: undefined,
@@ -514,5 +629,6 @@ export async function getRecommendedOrgs(): Promise {
category: org.category,
followerCount: 0,
isFollowing: false,
+ isHidden: false,
}));
}
diff --git a/apps/web/src/lib/event-visibility.ts b/apps/web/src/lib/event-visibility.ts
index 0a2892b..0e42ee5 100644
--- a/apps/web/src/lib/event-visibility.ts
+++ b/apps/web/src/lib/event-visibility.ts
@@ -1,4 +1,16 @@
-import { events, and, db, eq, inArray, or, orgMembers } from "@the-forum/database";
+import {
+ events,
+ and,
+ db,
+ eq,
+ inArray,
+ isNull,
+ lte,
+ notInArray,
+ or,
+ orgBlocks,
+ orgMembers,
+} from "@the-forum/database";
/**
* Event visibility — the ONE place this rule is defined. Server-only.
@@ -39,6 +51,7 @@ export function eventVisibleTo(viewerId: string) {
* friends' activity): published AND visible. Drafts never appear in discovery,
* even to their authors — they live in My Events and on the org page.
* Private published events still show for their creator and org managers.
+ * Discovery surfaces also apply `notFromHiddenOrg`, below.
*/
export function eventDiscoverableBy(viewerId: string) {
const discoverable = and(eq(events.status, "published"), eventVisibleTo(viewerId));
@@ -46,6 +59,39 @@ export function eventDiscoverableBy(viewerId: string) {
return discoverable;
}
+/** Subquery: ids of orgs the viewer has hidden, optionally only those hidden by `asOf`. */
+export function hiddenOrgIdsQuery(viewerId: string, asOf?: Date) {
+ return db
+ .select({ orgId: orgBlocks.orgId })
+ .from(orgBlocks)
+ .where(
+ asOf
+ ? and(eq(orgBlocks.userId, viewerId), lte(orgBlocks.createdAt, asOf))
+ : eq(orgBlocks.userId, viewerId),
+ );
+}
+
+/**
+ * WHERE fragment for discovery surfaces: the event is not hosted by an org the
+ * viewer hid ("Hide events from "). Events without an org always pass.
+ *
+ * Applied to Explore (every sort, and its search), the map, similar events and
+ * friends' activity. Deliberately NOT applied to the org's own page, direct
+ * event links, or the viewer's own RSVPs/saves — hiding an org filters
+ * discovery; it doesn't make its events unreachable.
+ *
+ * `asOf` limits it to orgs hidden by then, so a paginated feed keeps slicing
+ * the ranking page 1 came from (see docs/ranking.md, "Pagination").
+ */
+export function notFromHiddenOrg(viewerId: string, asOf?: Date) {
+ const clause = or(
+ isNull(events.orgId),
+ notInArray(events.orgId, hiddenOrgIdsQuery(viewerId, asOf)),
+ );
+ if (!clause) throw new Error("unreachable: empty hidden-org predicate");
+ return clause;
+}
+
/** WHERE fragment: the viewer may edit the event (creator, or owner/officer of its org). */
export function eventEditableBy(viewerId: string) {
const editable = or(
diff --git a/apps/web/src/lib/org-preferences.ts b/apps/web/src/lib/org-preferences.ts
new file mode 100644
index 0000000..cf35864
--- /dev/null
+++ b/apps/web/src/lib/org-preferences.ts
@@ -0,0 +1,62 @@
+import { and, db, eq, orgBlocks, orgFollowers } from "@the-forum/database";
+
+/**
+ * Per-viewer org preferences: follow (hoist in the feed) and hide (remove from
+ * discovery). Server-only; the server actions in `~/actions/orgs.ts` wrap these
+ * with auth, validation and rate limiting.
+ *
+ * The two are mutually exclusive — hiding an org unfollows it and following
+ * one unhides it — and each write happens in one transaction so the pair can
+ * never end up both set. Every write is idempotent.
+ */
+
+export interface OrgViewerState {
+ following: boolean;
+ hidden: boolean;
+}
+
+export async function loadOrgViewerState(userId: string, orgId: string): Promise {
+ const [[follow], [block]] = await Promise.all([
+ db
+ .select({ orgId: orgFollowers.orgId })
+ .from(orgFollowers)
+ .where(and(eq(orgFollowers.userId, userId), eq(orgFollowers.orgId, orgId)))
+ .limit(1),
+ db
+ .select({ orgId: orgBlocks.orgId })
+ .from(orgBlocks)
+ .where(and(eq(orgBlocks.userId, userId), eq(orgBlocks.orgId, orgId)))
+ .limit(1),
+ ]);
+ return { following: !!follow, hidden: !!block };
+}
+
+/** Follow `orgId`, clearing any hide. */
+export async function setFollowing(userId: string, orgId: string): Promise {
+ await db.transaction(async (tx) => {
+ await tx.delete(orgBlocks).where(and(eq(orgBlocks.userId, userId), eq(orgBlocks.orgId, orgId)));
+ await tx.insert(orgFollowers).values({ userId, orgId }).onConflictDoNothing();
+ });
+}
+
+export async function clearFollowing(userId: string, orgId: string): Promise {
+ await db
+ .delete(orgFollowers)
+ .where(and(eq(orgFollowers.userId, userId), eq(orgFollowers.orgId, orgId)));
+}
+
+/** Hide `orgId`, unfollowing it. Returns whether a follow was removed (so Undo can restore it). */
+export async function setHidden(userId: string, orgId: string): Promise<{ unfollowed: boolean }> {
+ return db.transaction(async (tx) => {
+ const removed = await tx
+ .delete(orgFollowers)
+ .where(and(eq(orgFollowers.userId, userId), eq(orgFollowers.orgId, orgId)))
+ .returning({ orgId: orgFollowers.orgId });
+ await tx.insert(orgBlocks).values({ userId, orgId }).onConflictDoNothing();
+ return { unfollowed: removed.length > 0 };
+ });
+}
+
+export async function clearHidden(userId: string, orgId: string): Promise {
+ await db.delete(orgBlocks).where(and(eq(orgBlocks.userId, userId), eq(orgBlocks.orgId, orgId)));
+}
diff --git a/apps/web/src/lib/rate-limit.ts b/apps/web/src/lib/rate-limit.ts
index 81e69db..7b54780 100644
--- a/apps/web/src/lib/rate-limit.ts
+++ b/apps/web/src/lib/rate-limit.ts
@@ -26,6 +26,8 @@ export const RATE_LIMITS = {
createEvent: { limit: 20, windowMs: HOUR },
createOrg: { limit: 5, windowMs: HOUR },
readEmail: { limit: 120, windowMs: MINUTE },
+ /** Follow / unfollow / hide / unhide an org. */
+ orgPreference: { limit: 60, windowMs: MINUTE },
} as const satisfies Record;
export type RateLimitName = keyof typeof RATE_LIMITS;
From c417f0d2f1cc0dff15958d3812195d501b94bf14 Mon Sep 17 00:00:00 2001
From: DIodide
Date: Mon, 28 Sep 2026 04:54:23 -0400
Subject: [PATCH 04/12] feed: listserv source signal, stronger follows, sorts,
hidden orgs
- For you: +2.5 x source quality (announced on a listserv: min(1, log2(1+n)/2);
unannounced MyPrincetonU listing: -0.3; Forum-created: +0.2). Org affinity
weight 1 -> 4 so followed orgs reach the first pages; diversity cap kept.
- Sorts: foryou | soonest | recent (coalesce(announced_at, created_at) desc),
validated with zod; same candidate set for every sort.
- Hidden orgs excluded from candidates in every sort, similar events and
friends' activity. Pinned asOf snapshots follows/hides/new events so
pagination is stable; orgs hidden since asOf drop out without shifting.
- FeedPage returns nextOffset/remaining; event detail exposes follow/hidden.
- Unit tests for scoring/sorts; DB-backed test for hides, sorts, paging.
- docs/ranking.md updated.
---
apps/web/src/actions/events.ts | 30 +-
apps/web/src/lib/feed-controls.db.test.ts | 255 ++++++++++++
apps/web/src/lib/feed-ranking.test.ts | 216 +++++++++++
apps/web/src/lib/feed-ranking.ts | 99 ++++-
apps/web/src/lib/feed.ts | 451 ++++++++++++++--------
apps/web/src/lib/validation.ts | 4 +
docs/ranking.md | 149 +++++--
7 files changed, 989 insertions(+), 215 deletions(-)
create mode 100644 apps/web/src/lib/feed-controls.db.test.ts
create mode 100644 apps/web/src/lib/feed-ranking.test.ts
diff --git a/apps/web/src/actions/events.ts b/apps/web/src/actions/events.ts
index 010ce66..8429a55 100644
--- a/apps/web/src/actions/events.ts
+++ b/apps/web/src/actions/events.ts
@@ -31,8 +31,10 @@ import {
eventDiscoverableBy,
eventEditableBy,
eventVisibleTo,
+ notFromHiddenOrg,
} from "~/lib/event-visibility";
import { type FeedPage, loadRankedFeed } from "~/lib/feed";
+import { loadOrgViewerState } from "~/lib/org-preferences";
import { enforceRateLimit } from "~/lib/rate-limit";
import { isOurImageUrl, uploadedImageUrlSchema } from "~/lib/s3";
import { loadFriendIds } from "~/lib/social-graph";
@@ -40,6 +42,7 @@ import {
dateInputSchema,
eventStatusSchema,
eventTagSchema,
+ feedSortSchema,
httpsUrlSchema,
idSchema,
locationIdSchema,
@@ -56,6 +59,8 @@ export interface FeedEvent {
orgName: string | null;
/** Organization logo (MyPrincetonU groups often have one), else null. */
orgLogoUrl?: string | null;
+ /** The viewer follows the host org — drives the card menu's Follow/Unfollow. Explore only. */
+ isFollowingOrg?: boolean;
datetime: string;
/** ISO timestamp, for building calendar links client-side. */
rawDatetime?: string;
@@ -172,6 +177,7 @@ const feedParamsSchema = z.object({
orgCategory: orgCategorySchema.optional(),
locationId: z.string().max(100).optional(),
dateRange: z.enum(["today", "week", "month"]).optional(),
+ sort: feedSortSchema.default("foryou"),
limit: z.number().int().min(1).max(50).default(20),
offset: z.number().int().min(0).max(5000).default(0),
asOf: z.iso.datetime().optional(),
@@ -184,18 +190,20 @@ export interface FeedParams {
orgCategory?: string;
locationId?: string;
dateRange?: "today" | "week" | "month";
+ /** "foryou" (default) | "soonest" | "recent". */
+ sort?: string;
limit?: number;
offset?: number;
asOf?: string;
}
/**
- * One page of the ranked Explore feed. Ranking, org-diversity and the
- * soon-event quota are applied to the whole candidate list before paginating;
- * see `~/lib/feed.ts` and docs/ranking.md.
+ * One page of the Explore feed in the requested sort. Ordering (for "For you":
+ * ranking, org-diversity and the soon-event quota) is applied to the whole
+ * candidate list before paginating; see `~/lib/feed.ts` and docs/ranking.md.
*
- * Pass the returned `asOf` back with the next `offset` so later pages are
- * slices of the same ranking.
+ * Pass the returned `asOf` and `nextOffset` back for the next page so later
+ * pages are slices of the same ordering.
*/
export async function getFeedEvents(params?: FeedParams): Promise {
const session = await auth();
@@ -209,6 +217,7 @@ export async function getFeedEvents(params?: FeedParams): Promise {
orgCategory: input.orgCategory,
locationId: input.locationId,
dateRange: input.dateRange,
+ sort: input.sort,
limit: input.limit,
offset: input.offset,
asOf: input.asOf ? new Date(input.asOf) : undefined,
@@ -340,6 +349,10 @@ export interface EventDetail {
isOwner: boolean;
/** The viewer may edit it: creator, or owner/officer of its org. */
canEdit: boolean;
+ /** The viewer follows the host org. */
+ isFollowingOrg: boolean;
+ /** The viewer hid the host org's events from discovery. */
+ isOrgHidden: boolean;
}
export async function getEvent(eventId: string): Promise {
@@ -382,9 +395,10 @@ export async function getEvent(eventId: string): Promise {
if (!event) return null;
- const [friendIds, canEdit] = await Promise.all([
+ const [friendIds, canEdit, orgState] = await Promise.all([
loadFriendIds(userId),
event.creatorId === userId ? Promise.resolve(true) : canEditEvent(eventId, userId),
+ event.orgId ? loadOrgViewerState(userId, event.orgId) : null,
]);
const extra = await loadEventEnrichment([eventId], userId, friendIds);
const attendees = extra.attendees.get(eventId) ?? [];
@@ -416,6 +430,8 @@ export async function getEvent(eventId: string): Promise {
isSaved: extra.savedByMe.has(eventId),
isOwner: event.creatorId === userId,
canEdit,
+ isFollowingOrg: orgState?.following ?? false,
+ isOrgHidden: orgState?.hidden ?? false,
};
}
@@ -439,6 +455,7 @@ export async function getSimilarEvents(
const conditions = [
gt(events.datetime, new Date()),
eventDiscoverableBy(userId),
+ notFromHiddenOrg(userId),
ne(events.id, input.eventId),
];
@@ -981,6 +998,7 @@ export async function getFriendsEvents(): Promise {
inArray(rsvps.userId, friendIds),
gt(events.datetime, new Date()),
eventDiscoverableBy(userId),
+ notFromHiddenOrg(userId),
),
)
.groupBy(
diff --git a/apps/web/src/lib/feed-controls.db.test.ts b/apps/web/src/lib/feed-controls.db.test.ts
new file mode 100644
index 0000000..a0a1089
--- /dev/null
+++ b/apps/web/src/lib/feed-controls.db.test.ts
@@ -0,0 +1,255 @@
+/**
+ * Feed controls against a real database: hidden orgs, the three sorts and
+ * stable pagination. Skipped unless FEED_TEST_DATABASE_URL points at a
+ * migrated database with upcoming events (e.g. after `db:sync-engine`):
+ *
+ * FEED_TEST_DATABASE_URL=postgres://… bun test src/lib/feed-controls.db.test.ts
+ *
+ * It creates one throwaway user and deletes it (cascading its follows and
+ * hides) at the end; event and org rows are only read.
+ */
+import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test";
+
+const url = process.env.FEED_TEST_DATABASE_URL;
+if (url) process.env.DATABASE_URL = url;
+
+const TEST_NET_ID = "_feedcontrols_test";
+let userId = "";
+
+mock.module("~/auth", () => ({
+ auth: async () => ({ user: { id: userId } }),
+}));
+
+describe.skipIf(!url)("feed controls (database)", () => {
+ let dbm: typeof import("@the-forum/database");
+ let feed: typeof import("~/lib/feed");
+ let prefs: typeof import("~/lib/org-preferences");
+ let map: typeof import("~/actions/map");
+ /** An org with several upcoming events in the next week, at least one mappable. */
+ let orgId = "";
+
+ /** Every event of one sort, paging with `asOf` the way the client does. */
+ async function allPages(sort: "foryou" | "soonest" | "recent", limit = 20) {
+ const ids: string[] = [];
+ let offset = 0;
+ let asOf: Date | undefined;
+ for (;;) {
+ const page = await feed.loadRankedFeed(userId, { sort, limit, offset, asOf });
+ ids.push(...page.events.map((e) => e.id));
+ asOf = new Date(page.asOf);
+ offset = page.nextOffset;
+ if (page.remaining === 0) return { ids, total: page.total };
+ }
+ }
+
+ async function orgEventIds(id: string) {
+ const rows = await dbm.db
+ .select({ id: dbm.events.id })
+ .from(dbm.events)
+ .where(dbm.eq(dbm.events.orgId, id));
+ return new Set(rows.map((r) => r.id));
+ }
+
+ beforeAll(async () => {
+ dbm = await import("@the-forum/database");
+ feed = await import("~/lib/feed");
+ prefs = await import("~/lib/org-preferences");
+ map = await import("~/actions/map");
+
+ await dbm.db.delete(dbm.users).where(dbm.eq(dbm.users.netId, TEST_NET_ID));
+ const [user] = await dbm.db
+ .insert(dbm.users)
+ .values({
+ netId: TEST_NET_ID,
+ email: `${TEST_NET_ID}@princeton.edu`,
+ displayName: "Feed Test",
+ onboarded: true,
+ })
+ .returning({ id: dbm.users.id });
+ if (!user) throw new Error("could not create test user");
+ userId = user.id;
+
+ const [busiest] = await dbm.db
+ .select({ orgId: dbm.events.orgId, n: dbm.sql`count(*)::int` })
+ .from(dbm.events)
+ .innerJoin(dbm.campusLocations, dbm.eq(dbm.events.locationId, dbm.campusLocations.id))
+ .where(
+ dbm.and(
+ dbm.eq(dbm.events.status, "published"),
+ dbm.eq(dbm.events.isPublic, true),
+ dbm.isNotNull(dbm.events.orgId),
+ dbm.gt(dbm.events.datetime, new Date()),
+ dbm.lt(dbm.events.datetime, new Date(Date.now() + 6 * 86_400_000)),
+ dbm.ne(dbm.campusLocations.latitude, 0),
+ ),
+ )
+ .groupBy(dbm.events.orgId)
+ .orderBy(dbm.desc(dbm.sql`count(*)`))
+ .limit(1);
+ if (!busiest?.orgId) throw new Error("no upcoming org events — sync some data first");
+ orgId = busiest.orgId;
+ });
+
+ afterAll(async () => {
+ if (userId) await dbm.db.delete(dbm.users).where(dbm.eq(dbm.users.id, userId));
+ });
+
+ test("pagination: every sort covers the same events, once each", async () => {
+ const forYou = await allPages("foryou");
+ const soonest = await allPages("soonest");
+ const recent = await allPages("recent", 7);
+ for (const run of [forYou, soonest, recent]) {
+ expect(new Set(run.ids).size).toBe(run.ids.length);
+ expect(run.ids.length).toBe(run.total);
+ }
+ expect(new Set(soonest.ids)).toEqual(new Set(forYou.ids));
+ expect(new Set(recent.ids)).toEqual(new Set(forYou.ids));
+ });
+
+ test("For you: announced events lead; unannounced MyPrincetonU listings fill in below", async () => {
+ const { ids } = await allPages("foryou");
+ const rows = await dbm.db
+ .select({ id: dbm.events.id, count: dbm.events.announcementCount })
+ .from(dbm.events)
+ .where(dbm.inArray(dbm.events.id, ids));
+ const announced = new Set(rows.filter((r) => r.count > 0).map((r) => r.id));
+ const positions = ids.map((id, i) => (announced.has(id) ? i : -1)).filter((i) => i >= 0);
+ if (announced.size === 0) return;
+ const meanAnnounced = positions.reduce((a, b) => a + b, 0) / positions.length;
+ console.log(
+ ` announced events: ${announced.size}/${ids.length}; mean position ${meanAnnounced.toFixed(1)}; ` +
+ `in top 20: ${positions.filter((p) => p < 20).length}`,
+ );
+ expect(meanAnnounced).toBeLessThan(ids.length / 4);
+ });
+
+ test("Recently posted: ordered by announcement time (else entry time), newest first", async () => {
+ const { ids } = await allPages("recent");
+ const rows = await dbm.db
+ .select({
+ id: dbm.events.id,
+ announcedAt: dbm.events.announcedAt,
+ createdAt: dbm.events.createdAt,
+ })
+ .from(dbm.events)
+ .where(dbm.inArray(dbm.events.id, ids));
+ const posted = new Map(rows.map((r) => [r.id, (r.announcedAt ?? r.createdAt).getTime()]));
+ for (let i = 1; i < ids.length; i++) {
+ expect(posted.get(ids[i] as string) as number).toBeLessThanOrEqual(
+ posted.get(ids[i - 1] as string) as number,
+ );
+ }
+ });
+
+ test("hidden org: absent from every sort, search and the map; back after unhiding", async () => {
+ const hiddenIds = await orgEventIds(orgId);
+ const before = await allPages("soonest");
+ const mapBefore = await map.getMapEvents({ days: 7 });
+ expect(before.ids.some((id) => hiddenIds.has(id))).toBe(true);
+ expect(mapBefore.some((e) => hiddenIds.has(e.id))).toBe(true);
+
+ await prefs.setHidden(userId, orgId);
+
+ for (const sort of ["foryou", "soonest", "recent"] as const) {
+ const { ids } = await allPages(sort);
+ expect(ids.some((id) => hiddenIds.has(id))).toBe(false);
+ expect(ids.length).toBe(
+ before.ids.length - before.ids.filter((id) => hiddenIds.has(id)).length,
+ );
+ }
+ const [orgRow] = await dbm.db
+ .select({ name: dbm.organizations.name })
+ .from(dbm.organizations)
+ .where(dbm.eq(dbm.organizations.id, orgId));
+ const [anyTitle] = await dbm.db
+ .select({ title: dbm.events.title })
+ .from(dbm.events)
+ .where(dbm.inArray(dbm.events.id, [...hiddenIds]))
+ .limit(1);
+ const searched = await feed.loadRankedFeed(userId, {
+ search: anyTitle?.title.slice(0, 20),
+ limit: 50,
+ offset: 0,
+ });
+ expect(searched.events.some((e) => hiddenIds.has(e.id))).toBe(false);
+ const mapAfter = await map.getMapEvents({ days: 7 });
+ expect(mapAfter.some((e) => hiddenIds.has(e.id))).toBe(false);
+ console.log(
+ ` hid "${orgRow?.name}": ${hiddenIds.size} events (all dates) hidden; none in any sort, search or the map ` +
+ `(map ${mapBefore.length} → ${mapAfter.length})`,
+ );
+
+ await prefs.clearHidden(userId, orgId);
+ const restored = await allPages("soonest");
+ expect(restored.ids).toEqual(before.ids);
+ });
+
+ test("hiding mid-scroll: later pages drop the org without shifting anything else", async () => {
+ const hiddenIds = await orgEventIds(orgId);
+ const full = await allPages("soonest", 10);
+
+ const page1 = await feed.loadRankedFeed(userId, { sort: "soonest", limit: 10, offset: 0 });
+ await prefs.setHidden(userId, orgId);
+ const rest: string[] = [];
+ let offset = page1.nextOffset;
+ for (;;) {
+ const page = await feed.loadRankedFeed(userId, {
+ sort: "soonest",
+ limit: 10,
+ offset,
+ asOf: new Date(page1.asOf),
+ });
+ rest.push(...page.events.map((e) => e.id));
+ offset = page.nextOffset;
+ if (page.remaining === 0) break;
+ }
+ await prefs.clearHidden(userId, orgId);
+
+ const seen = [...page1.events.map((e) => e.id), ...rest];
+ const expected = [
+ ...full.ids.slice(0, page1.events.length),
+ ...full.ids.slice(page1.events.length).filter((id) => !hiddenIds.has(id)),
+ ];
+ expect(seen).toEqual(expected);
+ });
+
+ test("hiding unfollows, and following unhides", async () => {
+ await prefs.setFollowing(userId, orgId);
+ expect(await prefs.setHidden(userId, orgId)).toEqual({ unfollowed: true });
+ expect(await prefs.loadOrgViewerState(userId, orgId)).toEqual({
+ following: false,
+ hidden: true,
+ });
+ await prefs.setFollowing(userId, orgId);
+ expect(await prefs.loadOrgViewerState(userId, orgId)).toEqual({
+ following: true,
+ hidden: false,
+ });
+ // Idempotent both ways.
+ await prefs.setFollowing(userId, orgId);
+ await prefs.clearFollowing(userId, orgId);
+ await prefs.clearFollowing(userId, orgId);
+ expect(await prefs.loadOrgViewerState(userId, orgId)).toEqual({
+ following: false,
+ hidden: false,
+ });
+ });
+
+ test("following an org raises its events in For you", async () => {
+ const orgIds = await orgEventIds(orgId);
+ const positions = (ids: string[]) =>
+ ids.map((id, i) => (orgIds.has(id) ? i : -1)).filter((i) => i >= 0);
+
+ const before = positions((await allPages("foryou")).ids);
+ await prefs.setFollowing(userId, orgId);
+ const after = positions((await allPages("foryou")).ids);
+ await prefs.clearFollowing(userId, orgId);
+
+ console.log(` org event positions: before [${before}] → followed [${after}]`);
+ expect(after.length).toBe(before.length);
+ expect(Math.min(...after)).toBeLessThanOrEqual(Math.min(...before));
+ expect(after.reduce((a, b) => a + b, 0)).toBeLessThan(before.reduce((a, b) => a + b, 0));
+ // The diversity cap still applies: at most 3 in the first 20.
+ expect(after.filter((p) => p < 20).length).toBeLessThanOrEqual(3);
+ });
+});
diff --git a/apps/web/src/lib/feed-ranking.test.ts b/apps/web/src/lib/feed-ranking.test.ts
new file mode 100644
index 0000000..f714f88
--- /dev/null
+++ b/apps/web/src/lib/feed-ranking.test.ts
@@ -0,0 +1,216 @@
+import { describe, expect, test } from "bun:test";
+import {
+ ORG_DIVERSITY_CAP,
+ ORG_DIVERSITY_WINDOW,
+ type ScoringContext,
+ type ScoringInput,
+ WEIGHTS,
+ compareRecentlyPosted,
+ compareScored,
+ compareSoonest,
+ finalizeFeedOrder,
+ postedAt,
+ scoreEvent,
+ sourceQuality,
+} from "./feed-ranking";
+
+const HOUR = 60 * 60 * 1000;
+const DAY = 24 * HOUR;
+const NOW = Date.parse("2026-09-28T16:00:00.000Z");
+
+const ctx = (overrides: Partial = {}): ScoringContext => ({
+ userId: "viewer",
+ now: NOW,
+ interestTags: new Set(),
+ myOrgIds: new Set(),
+ interactedOrgIds: new Set(),
+ ...overrides,
+});
+
+const event = (overrides: Partial & { id: string }): ScoringInput => ({
+ orgId: null,
+ startsAt: NOW + 2 * DAY,
+ postedAt: NOW - 10 * DAY,
+ tags: [],
+ friendsAttendingCount: 0,
+ viewCount: 0,
+ source: "myprincetonu",
+ announcementCount: 0,
+ ...overrides,
+});
+
+/** Score → sort → finalize, exactly as `loadRankedFeed` does for "For you". */
+function rankForYou(inputs: ScoringInput[], c: ScoringContext = ctx()) {
+ const scored = inputs.map((e) => ({ ...e, score: scoreEvent(e, c) }));
+ scored.sort(compareScored);
+ return finalizeFeedOrder(scored, { now: c.now });
+}
+
+describe("sourceQuality", () => {
+ test("listserv announcements boost, growing gently with repeats", () => {
+ expect(sourceQuality("listserv", 1)).toBeCloseTo(0.5);
+ expect(sourceQuality("listserv", 2)).toBeCloseTo(Math.log2(3) / 2);
+ expect(sourceQuality("listserv", 3)).toBe(1);
+ expect(sourceQuality("listserv", 12)).toBe(1);
+ });
+
+ test("an official event that was also emailed gets the same boost", () => {
+ expect(sourceQuality("myprincetonu", 1)).toBe(sourceQuality("listserv", 1));
+ });
+
+ test("unannounced MyPrincetonU listings sit below neutral; Forum events slightly above", () => {
+ expect(sourceQuality("myprincetonu", 0)).toBeLessThan(0);
+ expect(sourceQuality("manual", 0)).toBeGreaterThan(0);
+ expect(sourceQuality("manual", 0)).toBeLessThan(sourceQuality("listserv", 1));
+ expect(sourceQuality("legacy-import", 0)).toBe(0);
+ });
+});
+
+describe("For you: listserv-announced events outrank unannounced MyPrincetonU listings", () => {
+ test("otherwise-identical events: announced wins by more than the random nudge can undo", () => {
+ const c = ctx();
+ const announced = scoreEvent(event({ id: "a", source: "listserv", announcementCount: 1 }), c);
+ const official = scoreEvent(event({ id: "b", source: "myprincetonu" }), c);
+ expect(announced - official).toBeGreaterThan(WEIGHTS.random + 1);
+ });
+
+ test("an emailed official event beats an unannounced one", () => {
+ const c = ctx();
+ const emailed = scoreEvent(event({ id: "a", announcementCount: 2 }), c);
+ const silent = scoreEvent(event({ id: "b" }), c);
+ expect(emailed).toBeGreaterThan(silent + WEIGHTS.random);
+ });
+
+ test("a feed dominated by MyPrincetonU listings still leads with the announced ones", () => {
+ // 30 unannounced official listings and 5 announced events, all in the next
+ // four days, no personal signals — the shape of the real feed.
+ const inputs: ScoringInput[] = [];
+ for (let i = 0; i < 30; i++) {
+ inputs.push(event({ id: `mpu-${i}`, startsAt: NOW + (i % 4) * DAY + 6 * HOUR }));
+ }
+ for (let i = 0; i < 5; i++) {
+ inputs.push(
+ event({
+ id: `list-${i}`,
+ source: "listserv",
+ announcementCount: 1 + (i % 2),
+ startsAt: NOW + (i % 4) * DAY + 8 * HOUR,
+ }),
+ );
+ }
+ const order = rankForYou(inputs).map((e) => e.id);
+ const firstFive = order.slice(0, 5);
+ // The soon-event quota may pull an imminent listing forward, but the
+ // announced events all land in the first handful of positions.
+ const announcedPositions = order
+ .map((id, i) => (id.startsWith("list-") ? i : -1))
+ .filter((i) => i >= 0);
+ expect(Math.max(...announcedPositions)).toBeLessThan(8);
+ expect(firstFive.filter((id) => id.startsWith("list-")).length).toBeGreaterThanOrEqual(4);
+ });
+
+ test("among announced events, sooner still ranks higher", () => {
+ const c = ctx();
+ const tomorrow = scoreEvent(
+ event({ id: "a", source: "listserv", announcementCount: 1, startsAt: NOW + DAY }),
+ c,
+ );
+ const inThreeWeeks = scoreEvent(
+ event({ id: "b", source: "listserv", announcementCount: 1, startsAt: NOW + 21 * DAY }),
+ c,
+ );
+ expect(tomorrow).toBeGreaterThan(inThreeWeeks + WEIGHTS.random);
+ });
+});
+
+describe("For you: followed orgs rise", () => {
+ test("following an org adds the full org weight", () => {
+ const e = event({ id: "a", orgId: "org-1" });
+ const base = scoreEvent(e, ctx());
+ const followed = scoreEvent(e, ctx({ myOrgIds: new Set(["org-1"]) }));
+ expect(followed - base).toBeCloseTo(WEIGHTS.org);
+ });
+
+ test("a followed org's event two weeks out outranks an unfollowed one in two days", () => {
+ const c = ctx({ myOrgIds: new Set(["club"]) });
+ const followedFar = scoreEvent(event({ id: "a", orgId: "club", startsAt: NOW + 14 * DAY }), c);
+ const otherSoon = scoreEvent(event({ id: "b", orgId: "other", startsAt: NOW + 2 * DAY }), c);
+ expect(followedFar).toBeGreaterThan(otherSoon + WEIGHTS.random);
+ });
+
+ test("followed events reach the first page, without flooding it", () => {
+ const inputs: ScoringInput[] = [];
+ for (let i = 0; i < 60; i++) {
+ inputs.push(
+ event({
+ id: `other-${i}`,
+ orgId: `org-${i}`,
+ source: "listserv",
+ announcementCount: 1,
+ startsAt: NOW + (i % 10) * DAY + 5 * HOUR,
+ }),
+ );
+ }
+ for (let i = 0; i < 6; i++) {
+ inputs.push(event({ id: `club-${i}`, orgId: "club", startsAt: NOW + (10 + i * 2) * DAY }));
+ }
+
+ const unfollowed = rankForYou(inputs).map((e) => e.id);
+ const followed = rankForYou(inputs, ctx({ myOrgIds: new Set(["club"]) })).map((e) => e.id);
+
+ const firstPage = (order: string[]) =>
+ order.slice(0, ORG_DIVERSITY_WINDOW).filter((id) => id.startsWith("club-")).length;
+
+ expect(firstPage(unfollowed)).toBe(0);
+ // Hoisted onto page 1, but the org cap still limits it to 3 per 20.
+ expect(firstPage(followed)).toBe(ORG_DIVERSITY_CAP);
+ // Every followed event rises.
+ for (let i = 0; i < 6; i++) {
+ expect(followed.indexOf(`club-${i}`)).toBeLessThan(unfollowed.indexOf(`club-${i}`));
+ }
+ });
+});
+
+describe("Soonest", () => {
+ test("start time ascending, ties by id", () => {
+ const items = [
+ { id: "c", startsAt: NOW + 3 * DAY },
+ { id: "b", startsAt: NOW + DAY },
+ { id: "a", startsAt: NOW + DAY },
+ ];
+ expect([...items].sort(compareSoonest).map((e) => e.id)).toEqual(["a", "b", "c"]);
+ });
+});
+
+describe("Recently posted", () => {
+ test("posted time is the first announcement, else when it entered The Forum", () => {
+ const created = new Date(NOW - 5 * DAY);
+ const announced = new Date(NOW - 2 * HOUR);
+ expect(postedAt({ announcedAt: announced, createdAt: created })).toBe(announced.getTime());
+ expect(postedAt({ announcedAt: null, createdAt: created })).toBe(created.getTime());
+ });
+
+ test("ordered by announcement time, newest first", () => {
+ const rows = [
+ { id: "old-email", announcedAt: new Date(NOW - 3 * DAY), createdAt: new Date(NOW - HOUR) },
+ { id: "new-email", announcedAt: new Date(NOW - 1 * HOUR), createdAt: new Date(NOW) },
+ { id: "mpu-listing", announcedAt: null, createdAt: new Date(NOW - 2 * DAY) },
+ { id: "forum-post", announcedAt: null, createdAt: new Date(NOW - 5 * HOUR) },
+ ].map((r, i) => ({ id: r.id, postedAt: postedAt(r), startsAt: NOW + i * DAY }));
+
+ expect([...rows].sort(compareRecentlyPosted).map((e) => e.id)).toEqual([
+ "new-email",
+ "forum-post",
+ "mpu-listing",
+ "old-email",
+ ]);
+ });
+
+ test("same posted time falls back to soonest first", () => {
+ const items = [
+ { id: "later", postedAt: NOW, startsAt: NOW + 2 * DAY },
+ { id: "sooner", postedAt: NOW, startsAt: NOW + DAY },
+ ];
+ expect([...items].sort(compareRecentlyPosted).map((e) => e.id)).toEqual(["sooner", "later"]);
+ });
+});
diff --git a/apps/web/src/lib/feed-ranking.ts b/apps/web/src/lib/feed-ranking.ts
index 29bf781..62ef1da 100644
--- a/apps/web/src/lib/feed-ranking.ts
+++ b/apps/web/src/lib/feed-ranking.ts
@@ -27,13 +27,29 @@ export const CANDIDATE_HORIZON_DAYS = 45;
export const CANDIDATE_POOL_CAP = 1000;
export const PERSONAL_CANDIDATE_CAP = 500;
+// ── Sorts ─────────────────────────────────────────────────
+
+/**
+ * Home/Explore sort options. Every sort ranks the same candidate set (same
+ * filters, same horizon, blocked orgs excluded); only the order differs.
+ * - `foryou`: the weighted score below, plus the org-diversity cap and the
+ * soon-event quota.
+ * - `soonest`: start time ascending.
+ * - `recent`: when it was posted — first listserv announcement, else when it
+ * entered The Forum — newest first.
+ */
+export const FEED_SORTS = ["foryou", "soonest", "recent"] as const;
+export type FeedSort = (typeof FEED_SORTS)[number];
+export const DEFAULT_FEED_SORT: FeedSort = "foryou";
+
// ── Scoring ───────────────────────────────────────────────
export const WEIGHTS = {
interest: 3.0,
time: 2.0,
friends: 4.0,
- org: 1.0,
+ org: 4.0,
+ source: 2.5,
recency: 1.0,
popularity: 0.5,
random: 0.5,
@@ -42,8 +58,27 @@ export const WEIGHTS = {
/** Time proximity halves every this many days. */
export const TIME_HALF_LIFE_DAYS = 4;
-/** Org affinity when you've RSVP'd to the org before but don't follow/belong to it. */
-export const ORG_PAST_INTERACTION_AFFINITY = 0.5;
+/**
+ * Org affinity when you've RSVP'd to the org before but don't follow/belong to
+ * it. With WEIGHTS.org = 4 that is worth 0.6 — about what it was before
+ * following was strengthened — while a follow is worth the full 4.0.
+ */
+export const ORG_PAST_INTERACTION_AFFINITY = 0.15;
+
+/**
+ * Source quality, multiplied by WEIGHTS.source. Events announced on a campus
+ * listserv earn up to 1.0, growing gently with repeat announcements (see
+ * `sourceQuality`). Official MyPrincetonU listings nobody emailed about are
+ * numerous and uneven, so they sit slightly below neutral and fill in under
+ * announced events. Events created in The Forum are posted by a student on
+ * purpose: slightly positive.
+ */
+export const SOURCE_QUALITY = {
+ /** An announcement count that earns the full boost (log2(1 + 3) / 2 = 1). */
+ announcementsForFull: 3,
+ unannouncedOfficial: -0.3,
+ manual: 0.2,
+} as const;
/** View count treated as "maximally popular" (log-scaled, caps at 1.0). */
export const POPULARITY_VIEW_CAP = 50;
@@ -87,10 +122,32 @@ export interface ScoringInput {
id: string;
orgId: string | null;
startsAt: number;
- createdAt: number;
+ /** When the event was posted: first listserv announcement, else when it entered The Forum. */
+ postedAt: number;
tags: readonly string[];
friendsAttendingCount: number;
viewCount: number;
+ /** 'manual' | 'myprincetonu' | 'listserv' (or a legacy value, treated as neutral). */
+ source: string;
+ /** Campus listserv emails that announced the event (InboxEngine). */
+ announcementCount: number;
+}
+
+/**
+ * Source-quality signal in [-0.3, 1]:
+ * - announced on a listserv (any source): min(1, log2(1 + emails) / 2) —
+ * 0.5 for one email, ~0.79 for two, 1.0 from three on;
+ * - official MyPrincetonU listing never announced: SOURCE_QUALITY.unannouncedOfficial;
+ * - created in The Forum: SOURCE_QUALITY.manual;
+ * - anything else: 0.
+ */
+export function sourceQuality(source: string, announcementCount: number): number {
+ if (announcementCount > 0) {
+ return Math.min(1, Math.log2(1 + announcementCount) / 2);
+ }
+ if (source === "myprincetonu") return SOURCE_QUALITY.unannouncedOfficial;
+ if (source === "manual") return SOURCE_QUALITY.manual;
+ return 0;
}
export function scoreEvent(event: ScoringInput, ctx: ScoringContext): number {
@@ -114,8 +171,10 @@ export function scoreEvent(event: ScoringInput, ctx: ScoringContext): number {
? ORG_PAST_INTERACTION_AFFINITY
: 0;
- const hoursSinceCreated = (ctx.now - event.createdAt) / HOUR_MS;
- const recencyBoost = hoursSinceCreated <= 24 ? 1.0 : hoursSinceCreated <= 72 ? 0.5 : 0.0;
+ const hoursSincePosted = (ctx.now - event.postedAt) / HOUR_MS;
+ const recencyBoost = hoursSincePosted <= 24 ? 1.0 : hoursSincePosted <= 72 ? 0.5 : 0.0;
+
+ const source = sourceQuality(event.source, event.announcementCount);
const popularityScore = Math.min(
1.0,
@@ -131,6 +190,7 @@ export function scoreEvent(event: ScoringInput, ctx: ScoringContext): number {
WEIGHTS.time * timeProximity +
WEIGHTS.friends * friendRsvpScore +
WEIGHTS.org * orgAffinity +
+ WEIGHTS.source * source +
WEIGHTS.recency * recencyBoost +
WEIGHTS.popularity * popularityScore +
WEIGHTS.random * randomNudge
@@ -147,6 +207,33 @@ export function compareScored(
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
}
+/**
+ * When an event was posted, for "Recently posted" and the recency boost: its
+ * first listserv announcement if it had one, else when it entered The Forum
+ * (created there, or first synced from MyPrincetonU).
+ */
+export function postedAt(event: { announcedAt: Date | null; createdAt: Date }): number {
+ return (event.announcedAt ?? event.createdAt).getTime();
+}
+
+/** "Soonest": start time ascending, then id. */
+export function compareSoonest(
+ a: { id: string; startsAt: number },
+ b: { id: string; startsAt: number },
+): number {
+ if (a.startsAt !== b.startsAt) return a.startsAt - b.startsAt;
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
+}
+
+/** "Recently posted": posted time descending, then soonest first, then id. */
+export function compareRecentlyPosted(
+ a: { id: string; postedAt: number; startsAt: number },
+ b: { id: string; postedAt: number; startsAt: number },
+): number {
+ if (a.postedAt !== b.postedAt) return b.postedAt - a.postedAt;
+ return compareSoonest(a, b);
+}
+
export interface OrderingOptions {
now: number;
orgCap?: number;
diff --git a/apps/web/src/lib/feed.ts b/apps/web/src/lib/feed.ts
index 5178983..ef70b4f 100644
--- a/apps/web/src/lib/feed.ts
+++ b/apps/web/src/lib/feed.ts
@@ -3,6 +3,7 @@ import {
and,
campusLocations,
db,
+ desc,
eq,
eventTags,
gt,
@@ -11,6 +12,7 @@ import {
inArray,
interactions,
lt,
+ lte,
or,
orgFollowers,
orgMembers,
@@ -23,14 +25,18 @@ import {
} from "@the-forum/database";
import type { FeedEvent } from "~/actions/events";
import { formatEventDateTime } from "~/lib/date-format";
-import { eventDiscoverableBy } from "~/lib/event-visibility";
+import { eventDiscoverableBy, hiddenOrgIdsQuery, notFromHiddenOrg } from "~/lib/event-visibility";
import {
CANDIDATE_HORIZON_DAYS,
CANDIDATE_POOL_CAP,
+ type FeedSort,
PERSONAL_CANDIDATE_CAP,
type ScoringContext,
+ compareRecentlyPosted,
compareScored,
+ compareSoonest,
finalizeFeedOrder,
+ postedAt,
scoreEvent,
} from "~/lib/feed-ranking";
import { loadFriendIds } from "~/lib/social-graph";
@@ -45,6 +51,8 @@ export interface FeedQuery {
orgCategory?: OrgCategory;
locationId?: string;
dateRange?: "today" | "week" | "month";
+ /** Defaults to "foryou". */
+ sort?: FeedSort;
limit: number;
offset: number;
/**
@@ -57,21 +65,29 @@ export interface FeedQuery {
export interface FeedPage {
events: FeedEvent[];
- /** Size of the ranked candidate list — exactly what offset/limit can reach. */
+ /** Events in this ranking the viewer can still see (orgs hidden since `asOf` drop out). */
total: number;
+ /** Pass back as `offset` for the next page. */
+ nextOffset: number;
+ /** Visible events after `nextOffset` — 0 means this was the last page. */
+ remaining: number;
/** ISO timestamp the ranking was computed for; pass it back as `asOf` for the next page. */
asOf: string;
+ sort: FeedSort;
}
/** A stale `asOf` is ignored, so a tab left open overnight gets a fresh feed. */
const ASOF_MAX_AGE_MS = 30 * 60 * 1000;
-function resolveNow(asOf: Date | undefined): number {
+/** The ranking instant, and whether it was pinned to a client-supplied `asOf`. */
+function resolveNow(asOf: Date | undefined): { now: number; pinned: boolean } {
const realNow = Date.now();
- if (!asOf) return realNow;
+ if (!asOf) return { now: realNow, pinned: false };
const t = asOf.getTime();
- if (Number.isNaN(t) || t > realNow + 60_000 || realNow - t > ASOF_MAX_AGE_MS) return realNow;
- return t;
+ if (Number.isNaN(t) || t > realNow + 60_000 || realNow - t > ASOF_MAX_AGE_MS) {
+ return { now: realNow, pinned: false };
+ }
+ return { now: t, pinned: true };
}
function dateRangeEnd(now: number, dateRange: FeedQuery["dateRange"]): Date {
@@ -88,22 +104,36 @@ function dateRangeEnd(now: number, dateRange: FeedQuery["dateRange"]): Date {
return end;
}
+/** SQL twin of `postedAt()` in ~/lib/feed-ranking — keep the two in step. */
+const postedAtSql = sql`coalesce(${events.announcedAt}, ${events.createdAt})`;
+
/**
- * Rank the Explore feed for `userId` and return one page of it.
+ * One page of the Explore feed for `userId`, in the requested sort.
*
- * 1. Candidate generation (docs/ranking.md, "Candidate pool").
- * 2. Batched enrichment of every candidate — one `inArray` query per signal.
- * 3. Score, sort, then finalize ONE order over the whole list (org cap +
- * soon-event quota) before slicing out the requested page.
- * 4. Attendee rosters are loaded for the returned page only.
+ * 1. Candidate generation (docs/ranking.md, "Candidate pool") — the same set
+ * for every sort; hidden orgs are excluded here.
+ * 2. Order the whole candidate list. "For you" first enriches every candidate
+ * (one `inArray` query per signal), scores it, then applies the org cap and
+ * soon-event quota; "Soonest" and "Recently posted" are plain sorts.
+ * 3. Slice out the requested page, drop orgs hidden since `asOf`, and load the
+ * page's display data (tags, attendees, the viewer's RSVP/save state).
*/
export async function loadRankedFeed(userId: string, query: FeedQuery): Promise {
- const now = resolveNow(query.asOf);
+ const sort = query.sort ?? "foryou";
+ const { now, pinned } = resolveNow(query.asOf);
const nowDate = new Date(now);
const asOf = nowDate.toISOString();
+ /*
+ * On later pages (pinned to page 1's `asOf`), read viewer state as it was at
+ * `asOf`: follows and hides made since then don't reorder a ranking the
+ * client is already paging through, and events that entered The Forum since
+ * then wait for the next fresh load. Otherwise a follow or a new event could
+ * shift every later position by one, duplicating or skipping an event.
+ */
+ const snapshot = pinned ? nowDate : undefined;
// ── Viewer context ──────────────────────────────────────
- const [interestRows, friendIds, followedOrgRows, memberOrgRows, interactedOrgRows] =
+ const [interestRows, friendIds, followedOrgRows, memberOrgRows, interactedOrgRows, hiddenNow] =
await Promise.all([
db
.select({ tag: userInterests.tag })
@@ -113,7 +143,11 @@ export async function loadRankedFeed(userId: string, query: FeedQuery): Promise<
db
.select({ orgId: orgFollowers.orgId })
.from(orgFollowers)
- .where(eq(orgFollowers.userId, userId)),
+ .where(
+ snapshot
+ ? and(eq(orgFollowers.userId, userId), lte(orgFollowers.createdAt, snapshot))
+ : eq(orgFollowers.userId, userId),
+ ),
db.select({ orgId: orgMembers.orgId }).from(orgMembers).where(eq(orgMembers.userId, userId)),
// Orgs the user has RSVP'd to before — a weaker org-affinity signal.
db
@@ -121,6 +155,8 @@ export async function loadRankedFeed(userId: string, query: FeedQuery): Promise<
.from(rsvps)
.innerJoin(events, eq(rsvps.eventId, events.id))
.where(eq(rsvps.userId, userId)),
+ // Every org hidden right now, including since `asOf` — filtered from the page.
+ hiddenOrgIdsQuery(userId),
]);
const interestTags = interestRows.map((r) => r.tag);
@@ -131,6 +167,9 @@ export async function loadRankedFeed(userId: string, query: FeedQuery): Promise<
const interactedOrgIds = new Set(
interactedOrgRows.map((r) => r.orgId).filter((id): id is string => id !== null),
);
+ const hiddenOrgIds = new Set(hiddenNow.map((r) => r.orgId));
+ const isVisible = (item: { orgId: string | null }) =>
+ !item.orgId || !hiddenOrgIds.has(item.orgId);
// ── Candidate generation ────────────────────────────────
const conditions = [
@@ -138,7 +177,10 @@ export async function loadRankedFeed(userId: string, query: FeedQuery): Promise<
lt(events.datetime, dateRangeEnd(now, query.dateRange)),
// Published AND visible to this viewer — see ~/lib/event-visibility.ts.
eventDiscoverableBy(userId),
+ // Not from an org the viewer hid (as of `asOf` on later pages).
+ notFromHiddenOrg(userId, snapshot),
];
+ if (snapshot) conditions.push(lte(events.createdAt, snapshot));
if (query.search) {
const pattern = containsPattern(query.search);
@@ -183,45 +225,58 @@ export async function loadRankedFeed(userId: string, query: FeedQuery): Promise<
orgLogoUrl: organizations.logoUrl,
locationDetail: events.locationDetail,
createdAt: events.createdAt,
+ announcedAt: events.announcedAt,
+ announcementCount: events.announcementCount,
+ source: events.source,
};
- // Stage 1: everything within the horizon, soonest first, up to the cap.
+ // Stage 1: everything within the horizon, up to the cap — soonest first, or
+ // for "Recently posted" newest-posted first, so the cap (if it ever binds)
+ // keeps the events that sort would show first.
const timeOrdered = await db
.select(candidateColumns)
.from(events)
.leftJoin(campusLocations, eq(events.locationId, campusLocations.id))
.leftJoin(organizations, eq(events.orgId, organizations.id))
.where(and(...conditions))
- .orderBy(events.datetime, events.id)
+ .orderBy(
+ ...(sort === "recent"
+ ? [desc(postedAtSql), events.datetime, events.id]
+ : [events.datetime, events.id]),
+ )
.limit(CANDIDATE_POOL_CAP);
let candidates = timeOrdered;
- // Stage 2 (only if stage 1 saturated): events past the stage-1 cutoff that
- // carry a personal signal, so a dense calendar can't wall them off.
+ // Stage 2 ("For you" only, and only if stage 1 saturated): events past the
+ // stage-1 cutoff that carry a personal signal, so a dense calendar can't wall
+ // them off.
const lastTimeOrdered = timeOrdered[timeOrdered.length - 1];
if (timeOrdered.length === CANDIDATE_POOL_CAP && lastTimeOrdered) {
- const personalSignals = [
- myOrgIds.size > 0 ? inArray(events.orgId, [...myOrgIds]) : undefined,
- friendIds.length > 0
- ? inArray(
- events.id,
- db
- .select({ eventId: rsvps.eventId })
- .from(rsvps)
- .where(inArray(rsvps.userId, friendIds)),
- )
- : undefined,
- interestTags.length > 0
- ? inArray(
- events.id,
- db
- .select({ eventId: eventTags.eventId })
- .from(eventTags)
- .where(inArray(eventTags.tag, interestTags)),
- )
- : undefined,
- ].filter((c) => c !== undefined);
+ const personalSignals =
+ sort !== "foryou"
+ ? []
+ : [
+ myOrgIds.size > 0 ? inArray(events.orgId, [...myOrgIds]) : undefined,
+ friendIds.length > 0
+ ? inArray(
+ events.id,
+ db
+ .select({ eventId: rsvps.eventId })
+ .from(rsvps)
+ .where(inArray(rsvps.userId, friendIds)),
+ )
+ : undefined,
+ interestTags.length > 0
+ ? inArray(
+ events.id,
+ db
+ .select({ eventId: eventTags.eventId })
+ .from(eventTags)
+ .where(inArray(eventTags.tag, interestTags)),
+ )
+ : undefined,
+ ].filter((c) => c !== undefined);
if (personalSignals.length > 0) {
const personal = await db
@@ -244,117 +299,145 @@ export async function loadRankedFeed(userId: string, query: FeedQuery): Promise<
}
console.warn(
- `loadRankedFeed: time-ordered candidate pool hit CANDIDATE_POOL_CAP (${CANDIDATE_POOL_CAP}); ` +
- `ranked ${candidates.length} candidates after personalised expansion.`,
+ `loadRankedFeed: candidate pool hit CANDIDATE_POOL_CAP (${CANDIDATE_POOL_CAP}) for sort=${sort}; ` +
+ `ordering ${candidates.length} candidates.`,
);
}
- if (candidates.length === 0) return { events: [], total: 0, asOf };
+ if (candidates.length === 0) {
+ return { events: [], total: 0, nextOffset: 0, remaining: 0, asOf, sort };
+ }
- // ── Batched enrichment (one query per signal, whole candidate set) ──
- const candidateIds = candidates.map((e) => e.id);
- const [tagRows, rsvpCountRows, viewCountRows, friendRsvpRows, myRsvpRows, mySaveRows] =
- await Promise.all([
- db
- .select({ eventId: eventTags.eventId, tag: eventTags.tag })
- .from(eventTags)
- .where(inArray(eventTags.eventId, candidateIds)),
- db
- .select({ eventId: rsvps.eventId, count: sql`count(*)::int` })
- .from(rsvps)
- .where(inArray(rsvps.eventId, candidateIds))
- .groupBy(rsvps.eventId),
- // Distinct viewers, not raw view rows, so one user re-opening (or
- // scripting) an event can't inflate its popularity.
- db
- .select({
- eventId: interactions.itemId,
- count: sql`count(distinct ${interactions.userId})::int`,
- })
- .from(interactions)
- .where(
- and(
- inArray(interactions.itemId, candidateIds),
- eq(interactions.itemType, "event"),
- eq(interactions.interactionType, "view"),
- ),
- )
- .groupBy(interactions.itemId),
- friendIds.length === 0
- ? Promise.resolve([])
- : db
- .select({
- eventId: rsvps.eventId,
- id: users.id,
- displayName: users.displayName,
- avatarUrl: users.avatarUrl,
- })
- .from(rsvps)
- .innerJoin(users, eq(rsvps.userId, users.id))
- .where(and(inArray(rsvps.eventId, candidateIds), inArray(rsvps.userId, friendIds))),
- db
- .select({ eventId: rsvps.eventId })
- .from(rsvps)
- .where(and(eq(rsvps.userId, userId), inArray(rsvps.eventId, candidateIds))),
- db
- .select({ eventId: savedEvents.eventId })
- .from(savedEvents)
- .where(and(eq(savedEvents.userId, userId), inArray(savedEvents.eventId, candidateIds))),
- ]);
+ type Candidate = (typeof candidates)[number];
+ const items = candidates.map((event: Candidate) => ({
+ event,
+ id: event.id,
+ orgId: event.orgId,
+ startsAt: event.datetime.getTime(),
+ postedAt: postedAt(event),
+ }));
- const tagsByEvent = groupBy(
- tagRows,
- (r) => r.eventId,
- (r) => r.tag,
- );
- const friendsByEvent = groupBy(
- friendRsvpRows,
- (r) => r.eventId,
- (r) => ({ id: r.id, displayName: r.displayName, avatarUrl: r.avatarUrl }),
- );
- const rsvpCountByEvent = new Map(rsvpCountRows.map((r) => [r.eventId, r.count]));
- const viewCountByEvent = new Map(viewCountRows.map((r) => [r.eventId, r.count]));
- const myRsvps = new Set(myRsvpRows.map((r) => r.eventId));
- const mySaves = new Set(mySaveRows.map((r) => r.eventId));
-
- // ── Score, sort, finalize ───────────────────────────────
- const ctx: ScoringContext = {
- userId,
- now,
- interestTags: new Set(interestTags),
- myOrgIds,
- interactedOrgIds,
- };
+ // ── Order the whole list ────────────────────────────────
+ let ordered: typeof items;
+ /** Scoring signals, loaded for every candidate ("For you") or just the page. */
+ let signals: FeedSignals;
- const scored = candidates.map((event) => {
- const tags = tagsByEvent.get(event.id) ?? [];
- const friendsAttending = friendsByEvent.get(event.id) ?? [];
- const startsAt = event.datetime.getTime();
- const score = scoreEvent(
- {
- id: event.id,
- orgId: event.orgId,
- startsAt,
- createdAt: event.createdAt.getTime(),
- tags,
- friendsAttendingCount: friendsAttending.length,
- viewCount: viewCountByEvent.get(event.id) ?? 0,
- },
- ctx,
+ if (sort === "foryou") {
+ signals = await loadFeedSignals(
+ items.map((i) => i.id),
+ friendIds,
);
- return { event, tags, friendsAttending, startsAt, score, id: event.id, orgId: event.orgId };
- });
- scored.sort(compareScored);
+ const ctx: ScoringContext = {
+ userId,
+ now,
+ interestTags: new Set(interestTags),
+ myOrgIds,
+ interactedOrgIds,
+ };
+ const scored = items.map((item) => ({
+ ...item,
+ score: scoreEvent(
+ {
+ id: item.id,
+ orgId: item.orgId,
+ startsAt: item.startsAt,
+ postedAt: item.postedAt,
+ tags: signals.tags.get(item.id) ?? [],
+ friendsAttendingCount: signals.friends.get(item.id)?.length ?? 0,
+ viewCount: signals.views.get(item.id) ?? 0,
+ source: item.event.source,
+ announcementCount: item.event.announcementCount,
+ },
+ ctx,
+ ),
+ }));
+ scored.sort(compareScored);
+ ordered = finalizeFeedOrder(scored, { now });
+ } else {
+ ordered = [...items].sort(sort === "soonest" ? compareSoonest : compareRecentlyPosted);
+ signals = { tags: new Map(), friends: new Map(), views: new Map() };
+ }
- const ordered = finalizeFeedOrder(scored, { now });
- const page = ordered.slice(query.offset, query.offset + query.limit);
+ // ── Paginate ────────────────────────────────────────────
+ // Offsets index the full ordered list; orgs hidden since `asOf` are dropped
+ // from the output (and the counts) without shifting anyone's position.
+ const nextOffset = Math.min(ordered.length, query.offset + query.limit);
+ const page = ordered.slice(query.offset, nextOffset).filter(isVisible);
+ const total = hiddenOrgIds.size === 0 ? ordered.length : ordered.filter(isVisible).length;
+ const remaining = ordered.slice(nextOffset).filter(isVisible).length;
- // ── Attendee rosters: final page only ───────────────────
+ // ── Page display data ───────────────────────────────────
const pageIds = page.map((p) => p.id);
- const attendeeRows =
- pageIds.length === 0
- ? []
- : await db
+ if (sort !== "foryou") signals = await loadFeedSignals(pageIds, friendIds);
+ const details = await loadPageDetails(userId, pageIds);
+
+ return {
+ events: page.map(({ event }) => {
+ const attendees = details.attendees.get(event.id) ?? [];
+ return {
+ id: event.id,
+ title: event.title,
+ description: event.description,
+ orgId: event.orgId,
+ orgName: event.orgName,
+ orgLogoUrl: event.orgLogoUrl ?? null,
+ isFollowingOrg: event.orgId ? details.followedOrgIds.has(event.orgId) : false,
+ datetime: formatEventDateTime(event.datetime),
+ rawDatetime: event.datetime.toISOString(),
+ location: event.locationName ?? "TBD",
+ locationDetail: event.locationDetail ?? null,
+ tags: signals.tags.get(event.id) ?? [],
+ flyerUrl: event.flyerUrl,
+ rsvpCount: attendees.length,
+ friendsAttending: signals.friends.get(event.id) ?? [],
+ attendees,
+ isRsvped: details.rsvped.has(event.id),
+ isSaved: details.saved.has(event.id),
+ };
+ }),
+ total,
+ nextOffset,
+ remaining,
+ asOf,
+ sort,
+ };
+}
+
+type Person = { id: string; displayName: string; avatarUrl: string | null };
+
+interface FeedSignals {
+ tags: Map;
+ friends: Map;
+ views: Map;
+}
+
+/** Tags, friends attending and distinct-viewer counts — one query each, for any set of events. */
+async function loadFeedSignals(eventIds: string[], friendIds: string[]): Promise {
+ if (eventIds.length === 0) return { tags: new Map(), friends: new Map(), views: new Map() };
+ const [tagRows, viewCountRows, friendRsvpRows] = await Promise.all([
+ db
+ .select({ eventId: eventTags.eventId, tag: eventTags.tag })
+ .from(eventTags)
+ .where(inArray(eventTags.eventId, eventIds)),
+ // Distinct viewers, not raw view rows, so one user re-opening (or
+ // scripting) an event can't inflate its popularity.
+ db
+ .select({
+ eventId: interactions.itemId,
+ count: sql`count(distinct ${interactions.userId})::int`,
+ })
+ .from(interactions)
+ .where(
+ and(
+ inArray(interactions.itemId, eventIds),
+ eq(interactions.itemType, "event"),
+ eq(interactions.interactionType, "view"),
+ ),
+ )
+ .groupBy(interactions.itemId),
+ friendIds.length === 0
+ ? Promise.resolve([])
+ : db
.select({
eventId: rsvps.eventId,
id: users.id,
@@ -363,36 +446,66 @@ export async function loadRankedFeed(userId: string, query: FeedQuery): Promise<
})
.from(rsvps)
.innerJoin(users, eq(rsvps.userId, users.id))
- .where(inArray(rsvps.eventId, pageIds))
- .orderBy(rsvps.createdAt);
- const attendeesByEvent = groupBy(
- attendeeRows,
- (r) => r.eventId,
- (r) => ({ id: r.id, displayName: r.displayName, avatarUrl: r.avatarUrl }),
- );
+ .where(and(inArray(rsvps.eventId, eventIds), inArray(rsvps.userId, friendIds))),
+ ]);
+ return {
+ tags: groupBy(
+ tagRows,
+ (r) => r.eventId,
+ (r) => r.tag,
+ ),
+ friends: groupBy(
+ friendRsvpRows,
+ (r) => r.eventId,
+ (r) => ({ id: r.id, displayName: r.displayName, avatarUrl: r.avatarUrl }),
+ ),
+ views: new Map(viewCountRows.map((r) => [r.eventId, r.count])),
+ };
+}
+interface PageDetails {
+ attendees: Map;
+ rsvped: Set;
+ saved: Set;
+ /** Orgs the viewer follows right now — the card menu's Follow/Unfollow state. */
+ followedOrgIds: Set;
+}
+
+/** Attendee rosters, the viewer's RSVP/save state and current follows — for the returned page only. */
+async function loadPageDetails(userId: string, pageIds: string[]): Promise {
+ if (pageIds.length === 0) {
+ return { attendees: new Map(), rsvped: new Set(), saved: new Set(), followedOrgIds: new Set() };
+ }
+ const [attendeeRows, saveRows, followRows] = await Promise.all([
+ db
+ .select({
+ eventId: rsvps.eventId,
+ id: users.id,
+ displayName: users.displayName,
+ avatarUrl: users.avatarUrl,
+ })
+ .from(rsvps)
+ .innerJoin(users, eq(rsvps.userId, users.id))
+ .where(inArray(rsvps.eventId, pageIds))
+ .orderBy(rsvps.createdAt),
+ db
+ .select({ eventId: savedEvents.eventId })
+ .from(savedEvents)
+ .where(and(eq(savedEvents.userId, userId), inArray(savedEvents.eventId, pageIds))),
+ db
+ .select({ orgId: orgFollowers.orgId })
+ .from(orgFollowers)
+ .where(eq(orgFollowers.userId, userId)),
+ ]);
return {
- events: page.map(({ event, tags, friendsAttending }) => ({
- id: event.id,
- title: event.title,
- description: event.description,
- orgId: event.orgId,
- orgName: event.orgName,
- orgLogoUrl: event.orgLogoUrl ?? null,
- datetime: formatEventDateTime(event.datetime),
- rawDatetime: event.datetime.toISOString(),
- location: event.locationName ?? "TBD",
- locationDetail: event.locationDetail ?? null,
- tags,
- flyerUrl: event.flyerUrl,
- rsvpCount: rsvpCountByEvent.get(event.id) ?? 0,
- friendsAttending,
- attendees: attendeesByEvent.get(event.id) ?? [],
- isRsvped: myRsvps.has(event.id),
- isSaved: mySaves.has(event.id),
- })),
- total: ordered.length,
- asOf,
+ attendees: groupBy(
+ attendeeRows,
+ (r) => r.eventId,
+ (r) => ({ id: r.id, displayName: r.displayName, avatarUrl: r.avatarUrl }),
+ ),
+ rsvped: new Set(attendeeRows.filter((r) => r.id === userId).map((r) => r.eventId)),
+ saved: new Set(saveRows.map((r) => r.eventId)),
+ followedOrgIds: new Set(followRows.map((r) => r.orgId)),
};
}
diff --git a/apps/web/src/lib/validation.ts b/apps/web/src/lib/validation.ts
index ba04f4b..eadd62d 100644
--- a/apps/web/src/lib/validation.ts
+++ b/apps/web/src/lib/validation.ts
@@ -7,6 +7,7 @@ import {
orgCategoryEnum,
} from "@the-forum/database";
import { z } from "zod";
+import { FEED_SORTS } from "~/lib/feed-ranking";
/**
* Parse server-action input, throwing a readable Error on failure.
@@ -36,6 +37,9 @@ export const eventStatusSchema = z.enum(eventStatusEnum.enumValues);
export const interactionTypeSchema = z.enum(interactionTypeEnum.enumValues);
export const itemTypeSchema = z.enum(itemTypeEnum.enumValues);
+/** Explore sort ("For you" / "Soonest" / "Recently posted"), e.g. from `?sort=`. */
+export const feedSortSchema = z.enum(FEED_SORTS);
+
/** De-duplicated list of enum values (at most one of each). */
export function uniqueEnumArray(schema: E) {
return z
diff --git a/docs/ranking.md b/docs/ranking.md
index e4e082d..fd6ab25 100644
--- a/docs/ranking.md
+++ b/docs/ranking.md
@@ -3,17 +3,36 @@
This describes how `getFeedEvents()` (`apps/web/src/actions/events.ts`, implemented in `apps/web/src/lib/feed.ts`) orders the Explore
feed. It's a plain SQL + in-memory weighted score — no ML, no external service.
+## Sorts
+
+Home/Explore has a sort control next to the Cards/List toggle, persisted in the URL
+(`?sort=`) and remembered per browser:
+
+| Sort | `sort=` | Order |
+|---|---|---|
+| For you (default) | `foryou` | The weighted score below, then the org-diversity cap and soon-event quota |
+| Soonest | `soonest` | Start time ascending, then id |
+| Recently posted | `recent` | `coalesce(announced_at, created_at)` descending, then soonest, then id |
+
+Every sort orders the **same candidate set** — same filters (tags, search, …), same horizon,
+hidden orgs excluded — so switching sort never changes the count, only the order. "Posted" is
+when the event was first announced on a campus listserv (`events.announced_at`, from
+InboxEngine), else when it entered The Forum (created here, or first synced from
+MyPrincetonU). Soonest and Recently posted are deliberately plain sorts: no diversity cap, no
+soon quota — if you ask for chronological, you get chronological.
+
## The formula, in plain English
-Every upcoming event gets a score built from seven signals, each roughly between 0 and 1,
-multiplied by a weight:
+For "For you", every upcoming event gets a score built from eight signals, each roughly between
+0 and 1 (source quality: −0.3 to 1), multiplied by a weight:
| Signal | Weight | What it measures |
|---|---|---|
| Interest relevance | 3.0 | What fraction of this event's tags match your onboarding interests |
| Time proximity | 2.0 | How soon the event is — decays smoothly the further out it is |
| Friend RSVPs | 4.0 | How many of your friends are going (caps out at 3+) |
-| Org affinity | 1.0 | Whether you follow/belong to the hosting org, or have RSVP'd to it before |
+| Org affinity | 4.0 | Whether you follow/belong to the hosting org, or have RSVP'd to it before |
+| Source quality | 2.5 | Announced on a campus listserv (boost) vs. an unannounced MyPrincetonU listing (mild penalty) |
| Recency | 1.0 | Whether the event was posted in the last 1–3 days |
| Popularity | 0.5 | How many views the event has gotten, log-scaled |
| Random nudge | 0.5 | A small per-user-per-day nudge so the feed varies over time |
@@ -22,15 +41,17 @@ multiplied by a weight:
score = 3.0 × interest_relevance
+ 2.0 × time_proximity
+ 4.0 × friend_rsvp_score
- + 1.0 × org_affinity
+ + 4.0 × org_affinity
+ + 2.5 × source_quality
+ 1.0 × recency_boost
+ 0.5 × popularity_score
+ 0.5 × random_nudge
```
-Events are sorted by this score, highest first. Friend RSVPs carry the most weight — "people
-you know are going" is the strongest signal on a campus app. Popularity and the random nudge
-are deliberately small: they nudge the feed, they don't dominate it.
+Events are sorted by this score, highest first. Friend RSVPs and following the host carry the
+most weight — "people you know are going" and "you asked to see this org" are the strongest
+signals on a campus app. Popularity and the random nudge are deliberately small: they nudge the
+feed, they don't dominate it.
### Interest relevance
@@ -56,15 +77,45 @@ compelling; it doesn't climb further past that.
### Org affinity — tiered
-- **1.0** if you follow or belong to the event's org.
-- **0.5** if you don't, but you've RSVP'd to that org's events before
+- **1.0** (worth +4.0) if you follow or belong to the event's org.
+- **0.15** (worth +0.6) if you don't, but you've RSVP'd to that org's events before
(`ORG_PAST_INTERACTION_AFFINITY` in `lib/feed-ranking.ts`) — a weaker signal of interest.
- **0** otherwise, or if the event has no org.
+Following is an explicit "show me more of this", so it is weighted like friend RSVPs: +4.0
+equals the whole time-proximity range (2.0) and the gap between an announced and an
+unannounced listing (2.0) combined, so a followed org's event two or three weeks out still
+reaches the first page. The org
+diversity cap (below) still limits any one org to 3 of every 20 positions, so following a
+prolific org spreads its events across pages rather than flooding page 1.
+
+### Source quality — listserv announcements
+
+Official MyPrincetonU listings are numerous (most of the feed) and uneven in quality; an
+event someone bothered to announce on a campus listserv is a much better signal. InboxEngine
+reports, per event, how many listserv emails announced it (`events.announcement_count`: its
+own email for listserv-extracted events, plus reminder emails folded into it; official events
+that were also emailed count too).
+
+```
+announced (count ≥ 1): min(1, log2(1 + count) / 2) → +1.25 / +1.98 / +2.5 (1 / 2 / 3+ emails)
+unannounced MyPrincetonU (0): −0.3 → −0.75
+created in The Forum: +0.2 → +0.5
+anything else: 0
+```
+
+The boost grows gently with repeats (a reminder email is evidence, not a vote to spam) and
+saturates at 3. The gap between one announcement and an unannounced listing (2.0) equals the
+whole time-proximity range, so announced events lead and unannounced listings fill in below
+them rather than dominating by sheer number — while the soon-event quota still guarantees
+tonight's events a place near the top. Forum-created events are posted by a student on
+purpose, so they sit slightly above neutral, below an announced event.
+
### Recency boost
1.0 if posted in the last 24 hours, 0.5 if posted in the last 3 days, 0 otherwise — surfaces
-newly-posted events before other signals catch up.
+newly-posted events before other signals catch up. "Posted" is the same instant "Recently
+posted" sorts by: first listserv announcement, else when the event entered The Forum.
### Popularity
@@ -96,23 +147,28 @@ by soonest event first.
|---|---|
| `apps/web/src/actions/events.ts` → `getFeedEvents()` | Server action: auth + zod validation of params, then delegates |
| `apps/web/src/lib/feed.ts` → `loadRankedFeed()` | Candidate generation, batched enrichment, pagination |
-| `apps/web/src/lib/feed-ranking.ts` | Pure scoring + ordering (`scoreEvent`, `finalizeFeedOrder`) and every tunable constant |
+| `apps/web/src/lib/feed-ranking.ts` | Pure scoring + ordering (`scoreEvent`, `sourceQuality`, `finalizeFeedOrder`, `compareSoonest`, `compareRecentlyPosted`) and every tunable constant |
+| `apps/web/src/lib/event-visibility.ts` → `notFromHiddenOrg()` | The hidden-org filter shared by every discovery surface |
+| `apps/web/src/lib/feed-ranking.test.ts`, `feed-controls.db.test.ts` | Unit tests of the scoring/sorts; end-to-end checks against a synced database |
## Pipeline
-1. **Candidate generation** — which events get ranked at all (below).
-2. **Batched enrichment** — tags, RSVP counts, view counts, friends attending, and the viewer's
- own RSVP/save state for *every* candidate, one `inArray(...)` query per signal (six queries
- total, independent of pool size). No per-event queries.
-3. **Score + sort** — score desc, then soonest first, then event id. A total order, so the sort is
- fully deterministic.
-4. **Finalize one order over the whole list** — org-diversity cap and soon-event quota
- (below) are applied to the complete ranked list, *before* pagination.
-5. **Paginate** — the requested page is `finalOrder.slice(offset, offset + limit)`.
-6. **Attendee rosters** — the full "N attending" list is loaded for the returned page only.
-
-`total` is the length of the finalized list, so it always agrees with what `offset`/`limit` can
-reach.
+1. **Candidate generation** — which events get ranked at all (below). Identical for every sort.
+2. **Batched enrichment** ("For you" only) — tags, view counts and friends attending for
+ *every* candidate, one `inArray(...)` query per signal (three queries, independent of pool
+ size). No per-event queries.
+3. **Score + sort** ("For you") — score desc, then soonest first, then event id. A total order, so
+ the sort is fully deterministic. Soonest / Recently posted use their own total orders instead.
+4. **Finalize one order over the whole list** ("For you") — org-diversity cap and soon-event
+ quota (below) are applied to the complete ranked list, *before* pagination.
+5. **Paginate** — the requested page is `finalOrder.slice(offset, offset + limit)`, minus any
+ org hidden since `asOf` (see "Hiding organizations").
+6. **Page details** — attendee rosters, the viewer's RSVP/save/follow state (and, for the plain
+ sorts, tags and friends) are loaded for the returned page only.
+
+`total` counts the finalized list minus orgs hidden since `asOf`, `nextOffset` is where the next
+page starts and `remaining` how many visible events lie beyond it, so the numbers always agree
+with what the client can reach.
## Candidate pool
@@ -120,13 +176,14 @@ Scoring has to see more than one page — if only the 20 soonest events were sco
relevant event further out could never outrank a weak one that merely happens sooner.
**Stage 1 — calendar horizon.** Every published, visible, upcoming event matching the filters
-within the next `CANDIDATE_HORIZON_DAYS` (45) days, rounded up to end of day, soonest first, up
-to `CANDIDATE_POOL_CAP` (1000) rows. An explicit `dateRange` filter (`today`/`week`/`month`)
+within the next `CANDIDATE_HORIZON_DAYS` (45) days, rounded up to end of day, not hosted by an
+org the viewer hid, soonest first (newest-posted first for Recently posted), up to
+`CANDIDATE_POOL_CAP` (1000) rows. An explicit `dateRange` filter (`today`/`week`/`month`)
replaces the default horizon. At the expected scale (hundreds of events per semester) the cap
never binds and stage 1 *is* the whole pool. Scoring 1000 candidates in memory is a few
milliseconds; the batched enrichment queries stay well below Postgres' bind-parameter limit.
-**Stage 2 — personalised expansion (only if stage 1 saturates).** If stage 1 returns exactly
+**Stage 2 — personalised expansion (For you only, and only if stage 1 saturates).** If stage 1 returns exactly
`CANDIDATE_POOL_CAP` rows, a second query over the same filters and horizon fetches up to
`PERSONAL_CANDIDATE_CAP` (500) events *at or after* the stage-1 cutoff that carry a personal
signal: hosted by an org the viewer follows/belongs to, RSVP'd by a friend, or tagged with one
@@ -166,21 +223,40 @@ per org per page" wins over "3 soon events near the top".
The window is fixed, independent of `limit`/`offset`, so page size never changes the order.
+## Hiding organizations
+
+"Hide events from " (event card / event page menu, or the org page) writes a row to
+`org_blocks`. Hidden orgs are removed by `notFromHiddenOrg()` in
+`lib/event-visibility.ts` — never scored, in any sort — and the same filter is applied to the
+map, similar events, friends' activity ("Friends going") and org suggestions. It is
+deliberately *not* applied to the org's own page, direct event links, or your own RSVPs and
+saves: hiding filters discovery, it doesn't make events unreachable. Hiding and following are
+mutually exclusive (hiding unfollows, following unhides), and every write is idempotent.
+
## Pagination and `asOf`
Both rules above are part of one greedy pass (`finalizeFeedOrder`) over the full ranked list,
producing a permutation of it (nothing added or dropped). Pages are plain slices of that array,
-so within one ranking no event can appear on two pages or be skipped.
+so within one ranking no event can appear on two pages or be skipped. The same holds for the
+plain sorts, whose orders are total (ties broken by id).
Scores depend on "now" (time proximity, recency, which events count as soon, the daily nudge
-seed, and which events have already started). To keep page 2 a slice of the *same* ranking as
+seed, and which events have already started). To keep page 2 a slice of the *same* ordering as
page 1, every response includes `asOf` — the instant it was ranked for — and the client sends it
-back with the next `offset`. The server reuses it as "now" if it is at most 30 minutes old
-(otherwise it ranks fresh). Data that changes between requests (a new RSVP, a new event) can
-still shift the order slightly; the client de-duplicates by id when appending as a safety net.
+back with the next `offset` (the response's `nextOffset`) and the same sort. The server reuses
+it as "now" if it is at most 30 minutes old (otherwise it ranks fresh).
+
+A pinned `asOf` also pins the viewer state that could reorder the list: only follows, hides
+and events that existed at `asOf` are used for ordering. So following an org or a newly synced
+event can't shift every later position by one mid-scroll (it takes effect on the next fresh
+load). An org hidden *since* `asOf` is instead dropped from the output — from the page, `total`
+and `remaining` — without re-ranking, so nothing else moves: the card disappears immediately
+and page 2 continues exactly where page 1 ended. Remaining sources of drift (a new RSVP, an
+unfollow of an older follow) can still shift the order slightly; the client de-duplicates by
+id when appending as a safety net.
All tunable constants (`CANDIDATE_HORIZON_DAYS`, `CANDIDATE_POOL_CAP`, `PERSONAL_CANDIDATE_CAP`,
-`WEIGHTS`, `TIME_HALF_LIFE_DAYS`, `ORG_PAST_INTERACTION_AFFINITY`, `POPULARITY_VIEW_CAP`,
+`WEIGHTS`, `TIME_HALF_LIFE_DAYS`, `ORG_PAST_INTERACTION_AFFINITY`, `SOURCE_QUALITY`, `POPULARITY_VIEW_CAP`,
`ORG_DIVERSITY_CAP`, `ORG_DIVERSITY_WINDOW`, `SOON_WINDOW_DAYS`, `SOON_QUOTA`,
`SOON_INJECTION_WINDOW`) live at the top of `apps/web/src/lib/feed-ranking.ts`.
@@ -190,5 +266,10 @@ All tunable constants (`CANDIDATE_HORIZON_DAYS`, `CANDIDATE_POOL_CAP`, `PERSONAL
- **No friends**: `friend_rsvp_score` is 0; the friends-attending lookup is skipped entirely.
- **No org follows/memberships/past RSVPs**: `org_affinity` is 0.
+- **No announcement data** (e.g. rows synced before `announcement_count` existed): the sync
+ backfills them on its next run, since it rewrites any row whose stored announcement signals
+ differ from InboxEngine's.
+
None of these throw or produce an empty feed — a brand-new user with zero signals still gets
-a full feed, ranked by time proximity, recency, popularity, and the random nudge alone.
+a full feed, ranked by time proximity, source quality, recency, popularity, and the random
+nudge alone.
From d78982c90c1a4fcd3fb21460f863f732ffbcaf1f Mon Sep 17 00:00:00 2001
From: DIodide
Date: Mon, 28 Sep 2026 05:04:21 -0400
Subject: [PATCH 05/12] events: org overflow menu on cards and the event page
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A ⋯ menu (shadcn DropdownMenu) with Follow/Unfollow and Hide events
from , shown only for events with an org. Hiding shows a sonner toast
with Undo (restoring a follow the hide removed).
---
.../(app)/events/[id]/event-detail-client.tsx | 65 +++++++++
apps/web/src/components/events/event-card.tsx | 53 +++++--
.../web/src/components/orgs/org-feed-menu.tsx | 134 ++++++++++++++++++
3 files changed, 240 insertions(+), 12 deletions(-)
create mode 100644 apps/web/src/components/orgs/org-feed-menu.tsx
diff --git a/apps/web/src/app/(app)/events/[id]/event-detail-client.tsx b/apps/web/src/app/(app)/events/[id]/event-detail-client.tsx
index 302fe00..ed876d1 100644
--- a/apps/web/src/app/(app)/events/[id]/event-detail-client.tsx
+++ b/apps/web/src/app/(app)/events/[id]/event-detail-client.tsx
@@ -25,12 +25,14 @@ import {
toggleRsvp,
toggleSave,
} from "~/actions/events";
+import { toggleFollowOrg, unblockOrg } from "~/actions/orgs";
import { OrgAvatar } from "~/components/common/org-avatar";
import { RichText } from "~/components/common/rich-text";
import { AttendeesDialog } from "~/components/events/attendees-dialog";
import { EventCoverArt } from "~/components/events/event-cover-art";
import { MiniEventList } from "~/components/events/mini-event-list";
import { PageShell, SectionHeading } from "~/components/layout/page-shell";
+import { EventOrgMenu, hideOrgWithUndo } from "~/components/orgs/org-feed-menu";
import { AvatarStack } from "~/components/social/avatar-stack";
import { Button } from "~/components/ui/button";
import {
@@ -61,6 +63,8 @@ export function EventDetailClient({ event, similarEvents }: EventDetailClientPro
/* Local, because the avatar stack below has to move with the RSVP button. */
const [attendees, setAttendees] = useState(event.attendees);
const [deleteOpen, setDeleteOpen] = useState(false);
+ const [isFollowingOrg, setIsFollowingOrg] = useState(event.isFollowingOrg);
+ const [isOrgHidden, setIsOrgHidden] = useState(event.isOrgHidden);
/*
* Optimistic, then reconciled with the server. On failure the local state
@@ -115,6 +119,56 @@ export function EventDetailClient({ event, similarEvents }: EventDetailClientPro
}
};
+ /* Following unhides; hiding unfollows — mirrored locally. */
+ const handleToggleFollowOrg = async () => {
+ if (!event.orgId || !event.orgName) return;
+ const prev = { isFollowingOrg, isOrgHidden };
+ setIsFollowingOrg(!prev.isFollowingOrg);
+ if (!prev.isFollowingOrg) setIsOrgHidden(false);
+ try {
+ const { following } = await toggleFollowOrg(event.orgId);
+ setIsFollowingOrg(following);
+ toast(
+ following
+ ? `Following ${event.orgName}. Their events will rank higher.`
+ : `Unfollowed ${event.orgName}`,
+ );
+ } catch {
+ setIsFollowingOrg(prev.isFollowingOrg);
+ setIsOrgHidden(prev.isOrgHidden);
+ toast.error("Couldn't update that follow. Please try again.");
+ }
+ };
+
+ const handleHideOrg = () => {
+ if (!event.orgId || !event.orgName) return;
+ const wasFollowing = isFollowingOrg;
+ void hideOrgWithUndo({
+ orgId: event.orgId,
+ orgName: event.orgName,
+ onHidden: () => {
+ setIsOrgHidden(true);
+ setIsFollowingOrg(false);
+ },
+ onRestored: () => {
+ setIsOrgHidden(false);
+ setIsFollowingOrg(wasFollowing);
+ },
+ });
+ };
+
+ const handleUnhideOrg = async () => {
+ if (!event.orgId || !event.orgName) return;
+ setIsOrgHidden(false);
+ try {
+ await unblockOrg(event.orgId);
+ toast(`Events from ${event.orgName} will show in your feed again`);
+ } catch {
+ setIsOrgHidden(true);
+ toast.error("Couldn't update that. Please try again.");
+ }
+ };
+
const handleDelete = () => {
startTransition(async () => {
try {
@@ -251,6 +305,17 @@ export function EventDetailClient({ event, similarEvents }: EventDetailClientPro
Share
+ {event.orgId && event.orgName && (
+
+ )}
{/* Attendance */}
diff --git a/apps/web/src/components/events/event-card.tsx b/apps/web/src/components/events/event-card.tsx
index e723e92..d61e915 100644
--- a/apps/web/src/components/events/event-card.tsx
+++ b/apps/web/src/components/events/event-card.tsx
@@ -20,6 +20,7 @@ import { toast } from "sonner";
import { logInteraction } from "~/actions/interactions";
import { OrgAvatar } from "~/components/common/org-avatar";
import { AttendeesDialog } from "~/components/events/attendees-dialog";
+import { EventOrgMenu, menuTriggerRevealClass } from "~/components/orgs/org-feed-menu";
import { AvatarStack } from "~/components/social/avatar-stack";
import { Button } from "~/components/ui/button";
import { buildGCalUrl } from "~/lib/calendar";
@@ -97,6 +98,14 @@ export interface EventCardProps {
onUnhide?: () => void;
/** Extra action, e.g. the map's "Show on map". */
onLocate?: () => void;
+ /**
+ * Host-org actions, in the "⋯" menu: follow/unfollow (hoists the org in the
+ * feed) and hide its events. The menu renders only when the event has an org
+ * and both handlers are supplied.
+ */
+ isFollowingOrg?: boolean;
+ onToggleFollowOrg?: () => void;
+ onHideOrg?: () => void;
/**
* Open the event in place instead of navigating to its page. The map uses
* this so opening a card doesn't throw you off the map.
@@ -157,6 +166,9 @@ export function EventCard({
isHidden = false,
onUnhide,
onLocate,
+ isFollowingOrg = false,
+ onToggleFollowOrg,
+ onHideOrg,
onOpen,
density = "default",
calendarUrl,
@@ -224,7 +236,20 @@ export function EventCard({
logInteraction({ itemId: id, interactionType: "click", metadata: { source, position } });
};
- const hasUtilityRow = Boolean(onSaveToggle || onShare || onHide);
+ const orgMenu = (className: string, iconClassName?: string) =>
+ orgId && orgName && onToggleFollowOrg && onHideOrg ? (
+
+ ) : null;
+
+ const hasOrgMenu = Boolean(orgId && orgName && onToggleFollowOrg && onHideOrg);
+ const hasUtilityRow = Boolean(onSaveToggle || onShare || onHide || hasOrgMenu);
/*
* Hidden events collapse to a stub rather than disappearing. Removing the
@@ -391,6 +416,7 @@ export function EventCard({
)}
+ {orgMenu(cn(utilityButton, menuTriggerRevealClass))}
{editHref && (
)}
-
-
-
-
-
+
+ {orgMenu(UTILITY_HOVER, "text-forum-coral")}
+
+
+
+
+
+
)}
diff --git a/apps/web/src/components/orgs/org-feed-menu.tsx b/apps/web/src/components/orgs/org-feed-menu.tsx
new file mode 100644
index 0000000..d8d471e
--- /dev/null
+++ b/apps/web/src/components/orgs/org-feed-menu.tsx
@@ -0,0 +1,134 @@
+"use client";
+
+import { Eye, EyeOff, Heart, HeartOff, MoreHorizontal } from "lucide-react";
+import { toast } from "sonner";
+import { blockOrg, unblockOrg } from "~/actions/orgs";
+import { Button } from "~/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "~/components/ui/dropdown-menu";
+import { cn } from "~/lib/utils";
+
+/**
+ * The "⋯" menu on event cards and the event page: follow/unfollow the host
+ * org (hoists it in the feed) or hide its events (removes them from
+ * discovery). Render it only for events that have an org.
+ */
+export function EventOrgMenu({
+ orgName,
+ isFollowing,
+ isHidden = false,
+ onToggleFollow,
+ onHide,
+ onUnhide,
+ variant = "ghost",
+ className,
+ iconClassName,
+}: {
+ orgName: string;
+ isFollowing: boolean;
+ /** Already hidden (the event page only — hidden orgs never reach a feed card). */
+ isHidden?: boolean;
+ onToggleFollow: () => void;
+ onHide: () => void;
+ onUnhide?: () => void;
+ /** `outline` beside the event page's other outline buttons; `ghost` on cards. */
+ variant?: "ghost" | "outline";
+ className?: string;
+ iconClassName?: string;
+}) {
+ return (
+
+
+
+
+
+
+
+
+ {isFollowing ? : }
+
+ {isFollowing ? "Unfollow" : "Follow"} {orgName}
+
+
+ {isHidden && onUnhide ? (
+
+
+ Show events from {orgName}
+
+ ) : (
+
+
+ Hide events from {orgName}
+
+ )}
+
+
+ );
+}
+
+/**
+ * Hide an org, then offer Undo in a toast. `onHidden` runs immediately
+ * (optimistically) and `onSaved` once the server has it; `onRestored` runs if
+ * the request fails or Undo succeeds. Undo restores a follow the hide removed.
+ */
+export async function hideOrgWithUndo({
+ orgId,
+ orgName,
+ onHidden,
+ onSaved,
+ onRestored,
+ onFollowRestored,
+}: {
+ orgId: string;
+ orgName: string;
+ onHidden: () => void;
+ onSaved?: () => void;
+ onRestored: () => void;
+ /** Undo re-followed the org (the hide had unfollowed it). */
+ onFollowRestored?: () => void;
+}) {
+ onHidden();
+ let unfollowed = false;
+ try {
+ ({ unfollowed } = await blockOrg(orgId));
+ } catch {
+ onRestored();
+ toast.error(`Couldn't hide ${orgName}. Please try again.`);
+ return;
+ }
+ onSaved?.();
+ toast(`Hidden events from ${orgName}`, {
+ description: "Manage hidden organizations on the Orgs page.",
+ action: {
+ label: "Undo",
+ onClick: async () => {
+ try {
+ await unblockOrg(orgId, { refollow: unfollowed });
+ onRestored();
+ if (unfollowed) onFollowRestored?.();
+ } catch {
+ toast.error(`Couldn't undo. Try “Show in my feed” on ${orgName}'s page.`);
+ }
+ },
+ },
+ });
+}
+
+/** Class for the menu trigger inside hover-revealed utility rows: stays visible while open. */
+export const menuTriggerRevealClass = cn(
+ "md:opacity-0 md:group-hover:opacity-100 md:focus-visible:opacity-100",
+ "data-[state=open]:opacity-100",
+);
From 214e17a5643ca040d63c2a7eae2179e96182b045 Mon Sep 17 00:00:00 2001
From: DIodide
Date: Mon, 28 Sep 2026 05:04:21 -0400
Subject: [PATCH 06/12] home: sort control (For you / Soonest / Recently
posted); follow and hide from cards
Sort sits beside the Cards/List toggle, lives in ?sort= (zod-validated on the
server) and is remembered per browser. Pagination uses the server's
nextOffset/remaining with asOf, per sort. Hiding an org drops its cards at once
and re-reads the counts; following updates every card from that org.
---
.../src/app/(app)/explore/explore-client.tsx | 230 ++++++++++++++++--
apps/web/src/app/(app)/explore/page.tsx | 15 +-
.../src/components/events/feed-sort-menu.tsx | 59 +++++
3 files changed, 278 insertions(+), 26 deletions(-)
create mode 100644 apps/web/src/components/events/feed-sort-menu.tsx
diff --git a/apps/web/src/app/(app)/explore/explore-client.tsx b/apps/web/src/app/(app)/explore/explore-client.tsx
index 9eff653..c487ea7 100644
--- a/apps/web/src/app/(app)/explore/explore-client.tsx
+++ b/apps/web/src/app/(app)/explore/explore-client.tsx
@@ -1,7 +1,7 @@
"use client";
import Link from "next/link";
-import { useCallback, useEffect, useRef, useState, useTransition } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react";
import { toast } from "sonner";
import {
type FeedEvent,
@@ -10,24 +10,40 @@ import {
toggleRsvp,
toggleSave,
} from "~/actions/events";
+import { toggleFollowOrg } from "~/actions/orgs";
import { SearchInput } from "~/components/common/search-input";
import { EmptyState, ErrorState, EventCardSkeletonList } from "~/components/common/states";
import { EventCard } from "~/components/events/event-card";
import { EventCollection, EventViewToggle } from "~/components/events/event-collection";
import { EventFilters } from "~/components/events/event-filters";
+import { FeedSortMenu } from "~/components/events/feed-sort-menu";
import { MiniEventList } from "~/components/events/mini-event-list";
import { Greeting } from "~/components/layout/greeting";
import { PageShell, SectionHeading } from "~/components/layout/page-shell";
+import { hideOrgWithUndo } from "~/components/orgs/org-feed-menu";
import { Button } from "~/components/ui/button";
import { buildGCalUrl } from "~/lib/calendar";
import { formatRelativeDay } from "~/lib/date-format";
+import { DEFAULT_FEED_SORT, FEED_SORTS, type FeedSort } from "~/lib/feed-ranking";
import { useEventView } from "~/lib/use-event-view";
+const SORT_STORAGE_KEY = "forum:feed-sort";
+
+function isFeedSort(value: unknown): value is FeedSort {
+ return typeof value === "string" && (FEED_SORTS as readonly string[]).includes(value);
+}
+
interface ExploreClientProps {
initialEvents: FeedEvent[];
initialTotal: number;
+ /** Offset of the next page, and how many visible events lie beyond it. */
+ initialNextOffset: number;
+ initialRemaining: number;
/** The instant the first page was ranked at — echoed back so later pages slice the same ranking. */
initialAsOf: string;
+ initialSort: FeedSort;
+ /** `?sort=` was in the URL — it wins over the sort remembered in this browser. */
+ sortFromUrl: boolean;
savedEvents: FeedEvent[];
friendsEvents: FriendsEvent[];
initialSearch?: string;
@@ -38,7 +54,11 @@ interface ExploreClientProps {
export function ExploreClient({
initialEvents,
initialTotal,
+ initialNextOffset,
+ initialRemaining,
initialAsOf,
+ initialSort,
+ sortFromUrl,
savedEvents,
friendsEvents,
initialSearch = "",
@@ -51,6 +71,11 @@ export function ExploreClient({
* render on first load, which is precisely the case this screen has to handle.
*/
const [events, setEvents] = useState(initialEvents);
+ /** Latest `events`, for callbacks that outlive a render (the hide toast's Undo). */
+ const eventsRef = useRef(events);
+ useEffect(() => {
+ eventsRef.current = events;
+ }, [events]);
/*
* The full match count, not the page size. `getFeedEvents` pages at 20 while
* returning a separate count over every match, so reporting `events.length`
@@ -58,12 +83,16 @@ export function ExploreClient({
*/
const [total, setTotal] = useState(initialTotal);
/*
- * Pagination. `nextOffset` counts rows the server has handed us (not
- * `events.length`, which is after de-duplication), and `asOf` pins later
- * pages to the ranking page 1 came from.
+ * Pagination. `nextOffset` is the server's position in its ordering (not
+ * `events.length`, which is after de-duplication and hidden orgs), and
+ * `asOf` pins later pages to the ordering page 1 came from.
*/
- const [nextOffset, setNextOffset] = useState(initialEvents.length);
+ const [nextOffset, setNextOffset] = useState(initialNextOffset);
+ const [remaining, setRemaining] = useState(initialRemaining);
const [asOf, setAsOf] = useState(initialAsOf);
+ const [sort, setSort] = useState(initialSort);
+ /** Orgs hidden from this screen — their cards drop out at once, Undo brings them back. */
+ const [hiddenOrgIds, setHiddenOrgIds] = useState>(new Set());
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [activeFilters, setActiveFilters] = useState([]);
/*
@@ -83,10 +112,11 @@ export function ExploreClient({
* and without this the slower-but-older response lands last and wins.
*/
const latestRequest = useRef(0);
- /** Filters + search the current list was fetched with; "Load more" must reuse them. */
- const currentQuery = useRef<{ filters: string[]; search: string }>({
+ /** Filters, search and sort the current list was fetched with; "Load more" must reuse them. */
+ const currentQuery = useRef<{ filters: string[]; search: string; sort: FeedSort }>({
filters: [],
search: initialSearch.trim(),
+ sort: initialSort,
});
/** Drop a queued debounced search — it carries whatever filters were active when it was armed. */
@@ -97,20 +127,22 @@ export function ExploreClient({
}
}, []);
- const refreshEvents = useCallback((filters: string[], search: string) => {
+ const refreshEvents = useCallback((filters: string[], search: string, sort: FeedSort) => {
const requestId = ++latestRequest.current;
- currentQuery.current = { filters, search };
+ currentQuery.current = { filters, search, sort };
setIsLoadingMore(false);
startTransition(async () => {
try {
const result = await getFeedEvents({
tags: filters.length > 0 ? filters : undefined,
search: search || undefined,
+ sort,
});
if (requestId !== latestRequest.current) return;
setEvents(result.events);
setTotal(result.total);
- setNextOffset(result.events.length);
+ setNextOffset(result.nextOffset);
+ setRemaining(result.remaining);
setAsOf(result.asOf);
setLoadError(false);
} catch {
@@ -130,12 +162,13 @@ export function ExploreClient({
*/
const loadMore = useCallback(async () => {
const requestId = latestRequest.current;
- const { filters, search } = currentQuery.current;
+ const { filters, search, sort } = currentQuery.current;
setIsLoadingMore(true);
try {
const result = await getFeedEvents({
tags: filters.length > 0 ? filters : undefined,
search: search || undefined,
+ sort,
offset: nextOffset,
asOf,
});
@@ -145,7 +178,8 @@ export function ExploreClient({
return [...prev, ...result.events.filter((e) => !seen.has(e.id))];
});
setTotal(result.total);
- setNextOffset(nextOffset + result.events.length);
+ setNextOffset(result.nextOffset);
+ setRemaining(result.remaining);
setAsOf(result.asOf);
} catch {
if (requestId !== latestRequest.current) return;
@@ -155,7 +189,99 @@ export function ExploreClient({
}
}, [nextOffset, asOf]);
- const hasMore = nextOffset < total;
+ const hasMore = remaining > 0;
+
+ /*
+ * After hiding (or un-hiding) an org, re-read the counts for the ordering
+ * we're paging through — same `asOf` and offset — so "N upcoming events"
+ * and "Load more (N left)" stay true. The org's cards are already gone.
+ */
+ const refreshCounts = useCallback(async () => {
+ const requestId = latestRequest.current;
+ const { filters, search, sort } = currentQuery.current;
+ try {
+ const result = await getFeedEvents({
+ tags: filters.length > 0 ? filters : undefined,
+ search: search || undefined,
+ sort,
+ offset: nextOffset,
+ limit: 1,
+ asOf,
+ });
+ if (requestId !== latestRequest.current) return;
+ setTotal(result.total);
+ setRemaining(result.remaining + result.events.length);
+ } catch {
+ // Counts are cosmetic; the next page load corrects them.
+ }
+ }, [nextOffset, asOf]);
+
+ const visibleEvents = useMemo(
+ () =>
+ hiddenOrgIds.size === 0
+ ? events
+ : events.filter((e) => !e.orgId || !hiddenOrgIds.has(e.orgId)),
+ [events, hiddenOrgIds],
+ );
+
+ const setFollowingOrg = useCallback((orgId: string, following: boolean) => {
+ setEvents((prev) =>
+ prev.map((e) => (e.orgId === orgId ? { ...e, isFollowingOrg: following } : e)),
+ );
+ }, []);
+
+ /* Optimistic across every card from the org, then reconciled. */
+ const handleToggleFollowOrg = useCallback(
+ async (orgId: string, orgName: string, wasFollowing: boolean) => {
+ setFollowingOrg(orgId, !wasFollowing);
+ try {
+ const { following } = await toggleFollowOrg(orgId);
+ setFollowingOrg(orgId, following);
+ toast(
+ following
+ ? `Following ${orgName}. Their events will rank higher.`
+ : `Unfollowed ${orgName}`,
+ );
+ } catch {
+ setFollowingOrg(orgId, wasFollowing);
+ toast.error("Couldn't update that follow. Please try again.");
+ }
+ },
+ [setFollowingOrg],
+ );
+
+ const handleHideOrg = useCallback(
+ (orgId: string, orgName: string) => {
+ const showAgain = () => {
+ setHiddenOrgIds((prev) => {
+ const next = new Set(prev);
+ next.delete(orgId);
+ return next;
+ });
+ };
+ void hideOrgWithUndo({
+ orgId,
+ orgName,
+ onHidden: () => {
+ setHiddenOrgIds((prev) => new Set(prev).add(orgId));
+ setFollowingOrg(orgId, false);
+ },
+ onSaved: () => void refreshCounts(),
+ onRestored: () => {
+ showAgain();
+ // If the list was re-fetched while hidden, the org's events aren't
+ // loaded any more — fetch again rather than leave them missing.
+ if (eventsRef.current.some((e) => e.orgId === orgId)) void refreshCounts();
+ else {
+ const { filters, search, sort } = currentQuery.current;
+ refreshEvents(filters, search, sort);
+ }
+ },
+ onFollowRestored: () => setFollowingOrg(orgId, true),
+ });
+ },
+ [refreshCounts, refreshEvents, setFollowingOrg],
+ );
// A queued search outliving the component would fetch for a dead screen.
useEffect(() => cancelPendingSearch, [cancelPendingSearch]);
@@ -174,11 +300,50 @@ export function ExploreClient({
* already carries the current query, so nothing is lost by dropping it.
*/
cancelPendingSearch();
- refreshEvents(next, searchQuery.trim());
+ refreshEvents(next, searchQuery.trim(), sort);
+ },
+ [activeFilters, searchQuery, sort, refreshEvents, cancelPendingSearch],
+ );
+
+ /*
+ * Sort: kept in the URL (`?sort=`, omitted for the default) so it survives
+ * reloads and can be shared, and remembered per browser for visits that
+ * arrive without one. Storage can be unavailable, so every access is guarded.
+ */
+ const applySort = useCallback(
+ (next: FeedSort, remember: boolean) => {
+ setSort(next);
+ if (remember) {
+ try {
+ window.localStorage.setItem(SORT_STORAGE_KEY, next);
+ } catch {
+ // ignore — the choice just won't persist
+ }
+ }
+ const url = new URL(window.location.href);
+ if (next === DEFAULT_FEED_SORT) url.searchParams.delete("sort");
+ else url.searchParams.set("sort", next);
+ window.history.replaceState(window.history.state, "", url);
+ cancelPendingSearch();
+ refreshEvents(activeFilters, searchQuery.trim(), next);
},
[activeFilters, searchQuery, refreshEvents, cancelPendingSearch],
);
+ // A visit without `?sort=` picks up the sort this browser last used.
+ const restoredSort = useRef(false);
+ useEffect(() => {
+ if (restoredSort.current || sortFromUrl) return;
+ restoredSort.current = true;
+ let stored: string | null = null;
+ try {
+ stored = window.localStorage.getItem(SORT_STORAGE_KEY);
+ } catch {
+ return;
+ }
+ if (isFeedSort(stored) && stored !== initialSort) applySort(stored, false);
+ }, [sortFromUrl, initialSort, applySort]);
+
/*
* Debounced as you type, matching Orgs. Explore used to require Enter, so
* the two search fields behaved differently for no reason.
@@ -188,10 +353,10 @@ export function ExploreClient({
setSearchQuery(value);
if (searchTimeout.current) clearTimeout(searchTimeout.current);
searchTimeout.current = setTimeout(() => {
- refreshEvents(activeFilters, value.trim());
+ refreshEvents(activeFilters, value.trim(), sort);
}, 300);
},
- [activeFilters, refreshEvents],
+ [activeFilters, sort, refreshEvents],
);
/*
@@ -292,7 +457,10 @@ export function ExploreClient({
: `${total} ${total === 1 ? "event matches" : "events match"}`
: `${total} upcoming ${total === 1 ? "event" : "events"}`}
-
+
+ applySort(next, true)} />
+
+
{loadError ? (
@@ -301,12 +469,12 @@ export function ExploreClient({
description="Something went wrong fetching the feed."
onRetry={() => {
cancelPendingSearch();
- refreshEvents(activeFilters, searchQuery.trim());
+ refreshEvents(activeFilters, searchQuery.trim(), sort);
}}
/>
- ) : isPending && events.length === 0 ? (
+ ) : isPending && visibleEvents.length === 0 ? (
- ) : events.length === 0 ? (
+ ) : visibleEvents.length === 0 ? (
) : (
(
@@ -345,6 +513,22 @@ export function ExploreClient({
navigator.clipboard.writeText(`${window.location.origin}/events/${event.id}`);
toast.success("Link copied to clipboard");
}}
+ isFollowingOrg={event.isFollowingOrg}
+ onToggleFollowOrg={
+ event.orgId && event.orgName
+ ? () =>
+ handleToggleFollowOrg(
+ event.orgId as string,
+ event.orgName as string,
+ Boolean(event.isFollowingOrg),
+ )
+ : undefined
+ }
+ onHideOrg={
+ event.orgId && event.orgName
+ ? () => handleHideOrg(event.orgId as string, event.orgName as string)
+ : undefined
+ }
isHidden={hiddenIds.has(event.id)}
onHide={() => {
setHiddenIds((prev) => new Set(prev).add(event.id));
@@ -361,7 +545,7 @@ export function ExploreClient({
/>
)}
- {hasMore && !loadError && events.length > 0 && (
+ {hasMore && !loadError && visibleEvents.length > 0 && (
- {isLoadingMore ? "Loading…" : `Load more (${total - nextOffset} left)`}
+ {isLoadingMore ? "Loading…" : `Load more (${remaining} left)`}
)}
diff --git a/apps/web/src/app/(app)/explore/page.tsx b/apps/web/src/app/(app)/explore/page.tsx
index 40cb922..ae51a83 100644
--- a/apps/web/src/app/(app)/explore/page.tsx
+++ b/apps/web/src/app/(app)/explore/page.tsx
@@ -1,16 +1,21 @@
import { getFeedEvents, getFriendsEvents, getSavedEvents } from "~/actions/events";
import { auth } from "~/auth";
+import { DEFAULT_FEED_SORT } from "~/lib/feed-ranking";
+import { feedSortSchema } from "~/lib/validation";
import { ExploreClient } from "./explore-client";
export default async function ExplorePage({
searchParams,
}: {
- searchParams: Promise<{ search?: string }>;
+ searchParams: Promise<{ search?: string; sort?: string | string[] }>;
}) {
- const { search } = await searchParams;
+ const { search, sort: sortParam } = await searchParams;
+ // An unknown or repeated `?sort=` falls back to the default rather than erroring.
+ const parsedSort = feedSortSchema.safeParse(sortParam);
+ const sort = parsedSort.success ? parsedSort.data : DEFAULT_FEED_SORT;
const [feedResult, savedEvents, friendsEvents, session] = await Promise.all([
- getFeedEvents(search ? { search } : undefined),
+ getFeedEvents({ search: search || undefined, sort }),
getSavedEvents(),
getFriendsEvents(),
auth(),
@@ -20,7 +25,11 @@ export default async function ExplorePage({
= {
+ foryou: { label: "For you", short: "For you", hint: "Ranked for you" },
+ soonest: { label: "Soonest", short: "Soonest", hint: "Starting soonest first" },
+ recent: { label: "Recently posted", short: "Recent", hint: "Newest announcements first" },
+};
+
+/** Compact sort picker that sits beside the Cards/List toggle on Home. */
+export function FeedSortMenu({
+ sort,
+ onChange,
+ className,
+}: {
+ sort: FeedSort;
+ onChange: (sort: FeedSort) => void;
+ className?: string;
+}) {
+ const current = FEED_SORT_LABELS[sort];
+ return (
+
+
+
+ {current.short}
+ {current.label}
+
+
+
+ onChange(v as FeedSort)}>
+ {FEED_SORTS.map((id) => (
+
+ {FEED_SORT_LABELS[id].label}
+ {FEED_SORT_LABELS[id].hint}
+
+ ))}
+
+
+
+ );
+}
From e2a04c7d5e7ea62566e29b538f84a5c4da4750fc Mon Sep 17 00:00:00 2001
From: DIodide
Date: Mon, 28 Sep 2026 05:04:21 -0400
Subject: [PATCH 07/12] orgs: hide from my feed on the org page; Hidden filter
on Orgs
Org page keeps Follow primary and adds a secondary menu (Hide from my feed /
Show in my feed) plus a slim notice with Undo while hidden. The Orgs page gets a
Hidden chip (shown once something is hidden, /orgs?view=hidden) listing hidden
orgs with Unhide; hidden orgs are marked in the main list.
---
.../(app)/orgs/[id]/org-profile-client.tsx | 116 +++++++-
apps/web/src/app/(app)/orgs/orgs-client.tsx | 279 +++++++++++++-----
apps/web/src/app/(app)/orgs/page.tsx | 22 +-
3 files changed, 326 insertions(+), 91 deletions(-)
diff --git a/apps/web/src/app/(app)/orgs/[id]/org-profile-client.tsx b/apps/web/src/app/(app)/orgs/[id]/org-profile-client.tsx
index 860f9a1..d11b887 100644
--- a/apps/web/src/app/(app)/orgs/[id]/org-profile-client.tsx
+++ b/apps/web/src/app/(app)/orgs/[id]/org-profile-client.tsx
@@ -6,8 +6,11 @@ import {
ChevronLeft,
Copy,
ExternalLink,
+ Eye,
+ EyeOff,
Globe,
Heart,
+ MoreHorizontal,
Shield,
Users,
X,
@@ -17,7 +20,14 @@ import { useRouter } from "next/navigation";
import { useCallback, useState, useTransition } from "react";
import { toast } from "sonner";
import { type UserSearchResult, searchUsers } from "~/actions/friends";
-import { type OrgDetail, addOfficer, removeOfficer, toggleFollowOrg } from "~/actions/orgs";
+import {
+ type OrgDetail,
+ addOfficer,
+ blockOrg,
+ removeOfficer,
+ toggleFollowOrg,
+ unblockOrg,
+} from "~/actions/orgs";
import { OrgAvatar } from "~/components/common/org-avatar";
import { RichText } from "~/components/common/rich-text";
import { SearchInput } from "~/components/common/search-input";
@@ -26,6 +36,12 @@ import { EventCard } from "~/components/events/event-card";
import { PageShell } from "~/components/layout/page-shell";
import { OrgEmails } from "~/components/orgs/org-emails";
import { Button } from "~/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "~/components/ui/dropdown-menu";
import type { OrgEmails as OrgEmailsData } from "~/lib/inbox-engine";
const SOCIAL_LABELS: Record = {
@@ -86,15 +102,19 @@ export function OrgProfileClient({
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [isFollowing, setIsFollowing] = useState(org.isFollowing);
+ const [isHidden, setIsHidden] = useState(org.isHidden);
const [followerCount, setFollowerCount] = useState(org.followerCount);
const [team, setTeam] = useState(org.team);
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState([]);
const official = org.source === "myprincetonu";
+ /* Following also unhides (the two are mutually exclusive). */
const handleToggleFollow = () => {
const next = !isFollowing;
+ const wasHidden = isHidden;
setIsFollowing(next);
+ if (next) setIsHidden(false);
setFollowerCount((c) => c + (next ? 1 : -1));
startTransition(async () => {
try {
@@ -102,12 +122,41 @@ export function OrgProfileClient({
setIsFollowing(result.following);
} catch {
setIsFollowing(!next);
+ setIsHidden(wasHidden);
setFollowerCount((c) => c + (next ? -1 : 1));
toast.error("Couldn't update follow. Try again.");
}
});
};
+ /* Hiding also unfollows; showing again doesn't re-follow. */
+ const handleSetHidden = (hide: boolean) => {
+ const wasFollowing = isFollowing;
+ setIsHidden(hide);
+ if (hide && wasFollowing) {
+ setIsFollowing(false);
+ setFollowerCount((c) => c - 1);
+ }
+ startTransition(async () => {
+ try {
+ if (hide) await blockOrg(org.id);
+ else await unblockOrg(org.id);
+ toast(
+ hide
+ ? `${org.name}'s events are hidden from your feed`
+ : `${org.name}'s events will show in your feed again`,
+ );
+ } catch {
+ setIsHidden(!hide);
+ if (hide && wasFollowing) {
+ setIsFollowing(true);
+ setFollowerCount((c) => c + 1);
+ }
+ toast.error("Couldn't update that. Please try again.");
+ }
+ });
+ };
+
const handleSearch = useCallback(
async (query: string) => {
setSearchQuery(query);
@@ -287,18 +336,63 @@ export function OrgProfileClient({
)}
-
-
- {isFollowing ? "Following" : "Follow"}
-
+
+
+
+ {isFollowing ? "Following" : "Follow"}
+
+
+
+
+
+
+
+
+ {isHidden ? (
+ handleSetHidden(false)}>
+
+ Show in my feed
+
+ ) : (
+ handleSetHidden(true)}>
+
+ Hide from my feed
+
+ )}
+
+
+
+ {isHidden && (
+
+
+ You've hidden this organization's events
+
+ ·
+
+ handleSetHidden(false)}
+ className="font-medium text-forum-cerulean hover:underline disabled:opacity-50"
+ >
+ Undo
+
+
+ )}
+
{org.description && (
diff --git a/apps/web/src/app/(app)/orgs/orgs-client.tsx b/apps/web/src/app/(app)/orgs/orgs-client.tsx
index b8d5d11..d994571 100644
--- a/apps/web/src/app/(app)/orgs/orgs-client.tsx
+++ b/apps/web/src/app/(app)/orgs/orgs-client.tsx
@@ -1,15 +1,22 @@
"use client";
-import { Check, Plus, Users } from "lucide-react";
+import { Check, Eye, EyeOff, Plus, Users } from "lucide-react";
import Link from "next/link";
import { useCallback, useRef, useState, useTransition } from "react";
import { toast } from "sonner";
-import { type OrgListItem, getOrgs, toggleFollowOrg } from "~/actions/orgs";
+import {
+ type HiddenOrg,
+ type OrgListItem,
+ getOrgs,
+ toggleFollowOrg,
+ unblockOrg,
+} from "~/actions/orgs";
import { FilterChip, FilterChipGroup } from "~/components/common/filter-chip";
import { OrgAvatar } from "~/components/common/org-avatar";
import { SearchInput } from "~/components/common/search-input";
import { EmptyState } from "~/components/common/states";
import { Button } from "~/components/ui/button";
+import { formatTimeAgo } from "~/lib/date-format";
import { cn } from "~/lib/utils";
const ORG_CATEGORIES = [
@@ -26,16 +33,32 @@ const ORG_CATEGORIES = [
{ id: "community service", label: "Service" },
];
+/** Pseudo-category: the orgs the viewer hid from their feed. */
+const HIDDEN = "hidden";
+
interface OrgsClientProps {
initialOrgs: OrgListItem[];
recommendedOrgs: OrgListItem[];
+ /** Orgs the viewer hid, most recent first. */
+ initialHidden: HiddenOrg[];
+ /** Open on the Hidden filter (`/orgs?view=hidden`). */
+ showHidden?: boolean;
}
-export function OrgsClient({ initialOrgs, recommendedOrgs }: OrgsClientProps) {
+export function OrgsClient({
+ initialOrgs,
+ recommendedOrgs,
+ initialHidden,
+ showHidden = false,
+}: OrgsClientProps) {
const [isPending, startTransition] = useTransition();
const [orgs, setOrgs] = useState(initialOrgs);
+ const [hidden, setHidden] = useState(initialHidden);
const [searchQuery, setSearchQuery] = useState("");
- const [activeCategory, setActiveCategory] = useState("all");
+ const [activeCategory, setActiveCategory] = useState(
+ showHidden && initialHidden.length > 0 ? HIDDEN : "all",
+ );
+ const viewingHidden = activeCategory === HIDDEN;
const searchTimeout = useRef
>(null);
const refreshOrgs = useCallback((search?: string, category?: string) => {
@@ -51,14 +74,32 @@ export function OrgsClient({ initialOrgs, recommendedOrgs }: OrgsClientProps) {
const handleSearch = (query: string) => {
setSearchQuery(query);
if (searchTimeout.current) clearTimeout(searchTimeout.current);
+ // Searching always searches every org — leave the Hidden list for it.
+ const category = viewingHidden ? "all" : activeCategory;
+ if (viewingHidden) setActiveCategory("all");
searchTimeout.current = setTimeout(() => {
- refreshOrgs(query, activeCategory);
+ refreshOrgs(query, category);
}, 300);
};
const handleCategoryChange = (cat: string) => {
setActiveCategory(cat);
- refreshOrgs(searchQuery, cat);
+ if (cat !== HIDDEN) refreshOrgs(searchQuery, cat);
+ };
+
+ const handleUnhide = (org: HiddenOrg) => {
+ setHidden((prev) => prev.filter((o) => o.id !== org.id));
+ setOrgs((prev) => prev.map((o) => (o.id === org.id ? { ...o, isHidden: false } : o)));
+ startTransition(async () => {
+ try {
+ await unblockOrg(org.id);
+ toast(`Events from ${org.name} will show in your feed again`);
+ } catch {
+ setHidden((prev) => [org, ...prev]);
+ setOrgs((prev) => prev.map((o) => (o.id === org.id ? { ...o, isHidden: true } : o)));
+ toast.error("Couldn't unhide that organization. Please try again.");
+ }
+ });
};
const handleToggleFollow = (orgId: string) => {
@@ -70,12 +111,20 @@ export function OrgsClient({ initialOrgs, recommendedOrgs }: OrgsClientProps) {
followerCount: o.isFollowing ? o.followerCount - 1 : o.followerCount + 1,
}
: o;
+ const before = orgs.find((o) => o.id === orgId);
+ const hiddenBefore = hidden;
setOrgs((prev) => prev.map(flip));
+ // Following unhides.
+ if (before && !before.isFollowing && before.isHidden) {
+ setOrgs((prev) => prev.map((o) => (o.id === orgId ? { ...o, isHidden: false } : o)));
+ setHidden((prev) => prev.filter((o) => o.id !== orgId));
+ }
startTransition(async () => {
try {
await toggleFollowOrg(orgId);
} catch {
- setOrgs((prev) => prev.map(flip));
+ setOrgs((prev) => prev.map((o) => (o.id === orgId && before ? { ...before } : o)));
+ setHidden(hiddenBefore);
toast.error("Couldn't update that follow. Please try again.");
}
});
@@ -111,83 +160,161 @@ export function OrgsClient({ initialOrgs, recommendedOrgs }: OrgsClientProps) {
{label}
))}
+ {/* Only once something is hidden — or while viewing the list, so unhiding the last one doesn't yank it away. */}
+ {(hidden.length > 0 || viewingHidden) && (
+ handleCategoryChange(HIDDEN)}>
+
+ Hidden
+ {hidden.length}
+
+ )}
- {recommendedOrgs.length > 0 && (
-
- Suggested
- {recommendedOrgs.map((org, i) => (
-
-
- {org.name}
-
- {i < recommendedOrgs.length - 1 && · }
-
- ))}
-
- )}
-
- {orgs.length > 0 ? (
-
- {orgs.map((org) => {
- return (
-
-
-
+ {viewingHidden ? (
+
+ ) : (
+ <>
+ {recommendedOrgs.length > 0 && (
+
+
+ Suggested
+
+ {recommendedOrgs.map((org, i) => (
+
{org.name}
-
- {org.category} · {org.followerCount}{" "}
- {org.followerCount === 1 ? "follower" : "followers"}
- {org.description && (
- · {org.description}
- )}
-
-
- handleToggleFollow(org.id)}
- className={cn(
- "relative z-10 h-7 shrink-0 rounded-full px-3 text-[12px]",
- org.isFollowing && "text-forum-cerulean",
- )}
- >
- {org.isFollowing && }
- {org.isFollowing ? "Following" : "Follow"}
-
-
- );
- })}
-
- ) : (
-
+ {i < recommendedOrgs.length - 1 && · }
+
+ ))}
+
+ )}
+
+ {orgs.length > 0 ? (
+
+ {orgs.map((org) => {
+ return (
+
+
+
+
+ {org.name}
+
+
+ {org.isHidden && (
+ Hidden ·
+ )}
+ {org.category} · {org.followerCount}{" "}
+ {org.followerCount === 1 ? "follower" : "followers"}
+ {org.description && (
+ · {org.description}
+ )}
+
+
+ handleToggleFollow(org.id)}
+ className={cn(
+ "relative z-10 h-7 shrink-0 rounded-full px-3 text-[12px]",
+ org.isFollowing && "text-forum-cerulean",
+ )}
+ >
+ {org.isFollowing && }
+ {org.isFollowing ? "Following" : "Follow"}
+
+
+ );
+ })}
+
+ ) : (
+
+ )}
+ >
)}
);
}
+
+/** The Hidden filter: orgs whose events you removed from your feed, with Unhide. */
+function HiddenOrgList({
+ orgs,
+ onUnhide,
+}: {
+ orgs: HiddenOrg[];
+ onUnhide: (org: HiddenOrg) => void;
+}) {
+ if (orgs.length === 0) {
+ return (
+
+ );
+ }
+ return (
+ <>
+
+ Their events are left out of Home, the map and suggestions. Their pages and event links
+ still work.
+
+
+ {orgs.map((org) => (
+
+
+
+
+ {org.name}
+
+
+ {org.category} · hidden{" "}
+ {formatTimeAgo(new Date(org.hiddenAt))}
+
+
+ onUnhide(org)}
+ className="relative z-10 h-7 shrink-0 rounded-full px-3 text-[12px]"
+ >
+
+ Unhide
+
+
+ ))}
+
+ >
+ );
+}
diff --git a/apps/web/src/app/(app)/orgs/page.tsx b/apps/web/src/app/(app)/orgs/page.tsx
index 4b5d0c8..5bfa778 100644
--- a/apps/web/src/app/(app)/orgs/page.tsx
+++ b/apps/web/src/app/(app)/orgs/page.tsx
@@ -1,17 +1,31 @@
import type { Metadata } from "next";
-import { getOrgs, getRecommendedOrgs } from "~/actions/orgs";
+import { getBlockedOrgs, getOrgs, getRecommendedOrgs } from "~/actions/orgs";
import { PageHeading, PageShell } from "~/components/layout/page-shell";
import { OrgsClient } from "./orgs-client";
export const metadata: Metadata = { title: "Organizations" };
-export default async function OrgsPage() {
- const [orgs, recommended] = await Promise.all([getOrgs(), getRecommendedOrgs()]);
+export default async function OrgsPage({
+ searchParams,
+}: {
+ searchParams: Promise<{ view?: string | string[] }>;
+}) {
+ const { view } = await searchParams;
+ const [orgs, recommended, hidden] = await Promise.all([
+ getOrgs(),
+ getRecommendedOrgs(),
+ getBlockedOrgs(),
+ ]);
return (
Organizations
-
+
);
}
From e01b8a95e9bd80228a370177bc34c414f93fb713 Mon Sep 17 00:00:00 2001
From: DIodide
Date: Mon, 28 Sep 2026 05:06:42 -0400
Subject: [PATCH 08/12] orgs: bring the Hidden chip into view on phones; db
test mocks auth only when it runs
---
apps/web/src/app/(app)/orgs/orgs-client.tsx | 14 ++++++++++++--
apps/web/src/lib/feed-controls.db.test.ts | 9 ++++++---
2 files changed, 18 insertions(+), 5 deletions(-)
diff --git a/apps/web/src/app/(app)/orgs/orgs-client.tsx b/apps/web/src/app/(app)/orgs/orgs-client.tsx
index d994571..4800790 100644
--- a/apps/web/src/app/(app)/orgs/orgs-client.tsx
+++ b/apps/web/src/app/(app)/orgs/orgs-client.tsx
@@ -2,7 +2,7 @@
import { Check, Eye, EyeOff, Plus, Users } from "lucide-react";
import Link from "next/link";
-import { useCallback, useRef, useState, useTransition } from "react";
+import { useCallback, useEffect, useRef, useState, useTransition } from "react";
import { toast } from "sonner";
import {
type HiddenOrg,
@@ -59,6 +59,12 @@ export function OrgsClient({
showHidden && initialHidden.length > 0 ? HIDDEN : "all",
);
const viewingHidden = activeCategory === HIDDEN;
+ const hiddenChipRef = useRef(null);
+
+ // Arriving on /orgs?view=hidden: bring the chip into view in the scrolling row (phones).
+ useEffect(() => {
+ if (showHidden) hiddenChipRef.current?.scrollIntoView({ block: "nearest", inline: "nearest" });
+ }, [showHidden]);
const searchTimeout = useRef>(null);
const refreshOrgs = useCallback((search?: string, category?: string) => {
@@ -162,7 +168,11 @@ export function OrgsClient({
))}
{/* Only once something is hidden — or while viewing the list, so unhiding the last one doesn't yank it away. */}
{(hidden.length > 0 || viewingHidden) && (
- handleCategoryChange(HIDDEN)}>
+ handleCategoryChange(HIDDEN)}
+ >
Hidden
{hidden.length}
diff --git a/apps/web/src/lib/feed-controls.db.test.ts b/apps/web/src/lib/feed-controls.db.test.ts
index a0a1089..dbdc5c4 100644
--- a/apps/web/src/lib/feed-controls.db.test.ts
+++ b/apps/web/src/lib/feed-controls.db.test.ts
@@ -16,9 +16,12 @@ if (url) process.env.DATABASE_URL = url;
const TEST_NET_ID = "_feedcontrols_test";
let userId = "";
-mock.module("~/auth", () => ({
- auth: async () => ({ user: { id: userId } }),
-}));
+// Server actions (the map) read the session from ~/auth; stand in the test user.
+if (url) {
+ mock.module("~/auth", () => ({
+ auth: async () => ({ user: { id: userId } }),
+ }));
+}
describe.skipIf(!url)("feed controls (database)", () => {
let dbm: typeof import("@the-forum/database");
From c4114214d46807552169773a6d7aef075a13c5e9 Mon Sep 17 00:00:00 2001
From: DIodide
Date: Mon, 28 Sep 2026 05:07:45 -0400
Subject: [PATCH 09/12] orgs: scroll only the chip row to the Hidden chip
---
apps/web/src/app/(app)/orgs/orgs-client.tsx | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/apps/web/src/app/(app)/orgs/orgs-client.tsx b/apps/web/src/app/(app)/orgs/orgs-client.tsx
index 4800790..f14111b 100644
--- a/apps/web/src/app/(app)/orgs/orgs-client.tsx
+++ b/apps/web/src/app/(app)/orgs/orgs-client.tsx
@@ -61,9 +61,13 @@ export function OrgsClient({
const viewingHidden = activeCategory === HIDDEN;
const hiddenChipRef = useRef(null);
- // Arriving on /orgs?view=hidden: bring the chip into view in the scrolling row (phones).
+ /*
+ * Arriving on /orgs?view=hidden: scroll the chip row (not the page — which
+ * scrollIntoView would also move) to its end, where the Hidden chip sits.
+ */
useEffect(() => {
- if (showHidden) hiddenChipRef.current?.scrollIntoView({ block: "nearest", inline: "nearest" });
+ const row = hiddenChipRef.current?.parentElement;
+ if (showHidden && row) row.scrollLeft = row.scrollWidth;
}, [showHidden]);
const searchTimeout = useRef>(null);
From b995beb47f3387aed91a164287204b989fe37d4b Mon Sep 17 00:00:00 2001
From: DIodide
Date: Mon, 28 Sep 2026 05:09:07 -0400
Subject: [PATCH 10/12] filter chips: let the chip row scroll (fieldset
min-width) instead of overflowing on phones
---
apps/web/src/components/common/filter-chip.tsx | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/apps/web/src/components/common/filter-chip.tsx b/apps/web/src/components/common/filter-chip.tsx
index 1523949..d66b8d2 100644
--- a/apps/web/src/components/common/filter-chip.tsx
+++ b/apps/web/src/components/common/filter-chip.tsx
@@ -73,7 +73,12 @@ export function FilterChipGroup({
return (
{label}
From 6540f8b45a8a12778722629bfe3678a628aef4f3 Mon Sep 17 00:00:00 2001
From: DIodide
Date: Mon, 28 Sep 2026 05:14:53 -0400
Subject: [PATCH 11/12] =?UTF-8?q?cards:=20one=20hide=20control=20=E2=80=94?=
=?UTF-8?q?=20Not=20interested=20lives=20in=20the=20=E2=8B=AF=20menu?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The standalone per-event hide icon is gone from cards and list rows. The ⋯
menu is on every feed card: Not interested in this event (same stub + Unhide,
now with an Undo toast), then Follow/Unfollow and Hide events from the org
when there is one.
---
.../(app)/events/[id]/event-detail-client.tsx | 19 +--
apps/web/src/components/events/event-card.tsx | 108 ++++++++----------
.../web/src/components/orgs/org-feed-menu.tsx | 93 ++++++++-------
3 files changed, 108 insertions(+), 112 deletions(-)
diff --git a/apps/web/src/app/(app)/events/[id]/event-detail-client.tsx b/apps/web/src/app/(app)/events/[id]/event-detail-client.tsx
index ed876d1..96070ce 100644
--- a/apps/web/src/app/(app)/events/[id]/event-detail-client.tsx
+++ b/apps/web/src/app/(app)/events/[id]/event-detail-client.tsx
@@ -32,7 +32,7 @@ import { AttendeesDialog } from "~/components/events/attendees-dialog";
import { EventCoverArt } from "~/components/events/event-cover-art";
import { MiniEventList } from "~/components/events/mini-event-list";
import { PageShell, SectionHeading } from "~/components/layout/page-shell";
-import { EventOrgMenu, hideOrgWithUndo } from "~/components/orgs/org-feed-menu";
+import { EventActionsMenu, hideOrgWithUndo } from "~/components/orgs/org-feed-menu";
import { AvatarStack } from "~/components/social/avatar-stack";
import { Button } from "~/components/ui/button";
import {
@@ -306,13 +306,16 @@ export function EventDetailClient({ event, similarEvents }: EventDetailClientPro
Share
{event.orgId && event.orgName && (
-
)}
diff --git a/apps/web/src/components/events/event-card.tsx b/apps/web/src/components/events/event-card.tsx
index d61e915..644ab77 100644
--- a/apps/web/src/components/events/event-card.tsx
+++ b/apps/web/src/components/events/event-card.tsx
@@ -7,7 +7,6 @@ import {
Clock,
Edit3,
Eye,
- EyeOff,
MapPin,
Maximize2,
Plus,
@@ -20,7 +19,7 @@ import { toast } from "sonner";
import { logInteraction } from "~/actions/interactions";
import { OrgAvatar } from "~/components/common/org-avatar";
import { AttendeesDialog } from "~/components/events/attendees-dialog";
-import { EventOrgMenu, menuTriggerRevealClass } from "~/components/orgs/org-feed-menu";
+import { EventActionsMenu } from "~/components/orgs/org-feed-menu";
import { AvatarStack } from "~/components/social/avatar-stack";
import { Button } from "~/components/ui/button";
import { buildGCalUrl } from "~/lib/calendar";
@@ -92,6 +91,7 @@ export interface EventCardProps {
onSaveToggle?: () => void | Promise;
onRsvpToggle?: () => void | Promise;
onShare?: () => void;
+ /** "Not interested in this event", in the card's "⋯" menu. */
onHide?: () => void;
/** When true the card collapses to a stub that can be restored. */
isHidden?: boolean;
@@ -236,20 +236,48 @@ export function EventCard({
logInteraction({ itemId: id, interactionType: "click", metadata: { source, position } });
};
- const orgMenu = (className: string, iconClassName?: string) =>
- orgId && orgName && onToggleFollowOrg && onHideOrg ? (
-
- ) : null;
+ /*
+ * "Not interested": the per-event hide. The card collapses to a stub with
+ * Unhide, and the toast offers the same Undo.
+ */
+ const handleNotInterested = onHide
+ ? () => {
+ logInteraction({ itemId: id, interactionType: "hide", metadata: { source, position } });
+ onHide();
+ toast(`Hid ${title}`, {
+ description: "We'll show you fewer events like this.",
+ action: onUnhide ? { label: "Undo", onClick: onUnhide } : undefined,
+ });
+ }
+ : undefined;
+
+ const orgActions =
+ orgId && orgName && onToggleFollowOrg && onHideOrg
+ ? {
+ name: orgName,
+ isFollowing: isFollowingOrg,
+ onToggleFollow: onToggleFollowOrg,
+ onHide: onHideOrg,
+ }
+ : null;
+
+ /*
+ * The single "⋯" menu: "Not interested", then Follow / Hide the host org.
+ * Present on every feed card (org-less events get "Not interested" alone),
+ * so the top row reads the same on every card.
+ */
+ const actionsMenu = (className: string, iconClassName?: string) => (
+
+ );
- const hasOrgMenu = Boolean(orgId && orgName && onToggleFollowOrg && onHideOrg);
- const hasUtilityRow = Boolean(onSaveToggle || onShare || onHide || hasOrgMenu);
+ const hasActionsMenu = Boolean(handleNotInterested || orgActions);
+ const hasUtilityRow = Boolean(onSaveToggle || onShare || hasActionsMenu);
/*
* Hidden events collapse to a stub rather than disappearing. Removing the
@@ -374,27 +402,6 @@ export function EventCard({
- {onHide && (
- {
- logInteraction({
- itemId: id,
- interactionType: "hide",
- metadata: { source, position },
- });
- onHide();
- }}
- >
-
-
- )}
{onShare && (
)}
- {orgMenu(cn(utilityButton, menuTriggerRevealClass))}
+ {actionsMenu(utilityButton)}
{editHref && (
)}
- {/* Hide: revealed on hover/focus on desktop so the resting card shows the
- Figma's two icons; always visible on touch. */}
- {onHide && (
- {
- e.preventDefault();
- logInteraction({
- itemId: id,
- interactionType: "hide",
- metadata: { source, position },
- });
- onHide();
- toast(`Hid ${title}`, { description: "Use Unhide to bring it back." });
- }}
- >
-
-
- )}
- {orgMenu(UTILITY_HOVER, "text-forum-coral")}
+ {actionsMenu(UTILITY_HOVER, "text-forum-coral")}
— hoists the org in the feed;
+ * 3. Hide events from — removes the org from discovery.
+ * Org items appear only when the event has an org, so an org-less card's menu
+ * holds just "Not interested". Renders nothing when there is nothing to offer.
*/
-export function EventOrgMenu({
- orgName,
- isFollowing,
- isHidden = false,
- onToggleFollow,
- onHide,
- onUnhide,
+export function EventActionsMenu({
+ eventTitle,
+ onNotInterested,
+ org,
variant = "ghost",
className,
iconClassName,
}: {
- orgName: string;
- isFollowing: boolean;
- /** Already hidden (the event page only — hidden orgs never reach a feed card). */
- isHidden?: boolean;
- onToggleFollow: () => void;
- onHide: () => void;
- onUnhide?: () => void;
+ eventTitle: string;
+ /** Per-event hide; omitted where events can't be hidden (the event page). */
+ onNotInterested?: () => void;
+ org?: {
+ name: string;
+ isFollowing: boolean;
+ /** Already hidden (the event page only — hidden orgs never reach a feed card). */
+ isHidden?: boolean;
+ onToggleFollow: () => void;
+ onHide: () => void;
+ onUnhide?: () => void;
+ } | null;
/** `outline` beside the event page's other outline buttons; `ghost` on cards. */
variant?: "ghost" | "outline";
className?: string;
iconClassName?: string;
}) {
+ if (!onNotInterested && !org) return null;
return (
@@ -57,23 +63,34 @@ export function EventOrgMenu({
collisionPadding={8}
className="w-max min-w-56 max-w-[min(22rem,calc(100vw-2rem))] font-dm-sans"
>
-
- {isFollowing ? : }
-
- {isFollowing ? "Unfollow" : "Follow"} {orgName}
-
-
- {isHidden && onUnhide ? (
-
-
- Show events from {orgName}
-
- ) : (
-
-
- Hide events from {orgName}
+ {onNotInterested && (
+
+
+ Not interested in this event
)}
+ {onNotInterested && org && }
+ {org && (
+ <>
+
+ {org.isFollowing ? : }
+
+ {org.isFollowing ? "Unfollow" : "Follow"} {org.name}
+
+
+ {org.isHidden && org.onUnhide ? (
+
+
+ Show events from {org.name}
+
+ ) : (
+
+
+ Hide events from {org.name}
+
+ )}
+ >
+ )}
);
@@ -126,9 +143,3 @@ export async function hideOrgWithUndo({
},
});
}
-
-/** Class for the menu trigger inside hover-revealed utility rows: stays visible while open. */
-export const menuTriggerRevealClass = cn(
- "md:opacity-0 md:group-hover:opacity-100 md:focus-visible:opacity-100",
- "data-[state=open]:opacity-100",
-);
From 85e07df90c2bcc2cf9eb8c3c10cc49daae826965 Mon Sep 17 00:00:00 2001
From: DIodide
Date: Mon, 28 Sep 2026 05:14:53 -0400
Subject: [PATCH 12/12] greeting: first name from the database, or just Hello,
when there's none
A display name equal to the NetID (new accounts) or empty counts as no name.
Home reads it from the database, since the session name is captured at
sign-in and stays the NetID after onboarding. Onboarding's completion line
follows the same rule.
---
apps/web/src/actions/users.ts | 11 +++++++++++
.../src/app/(app)/explore/explore-client.tsx | 7 ++++---
apps/web/src/app/(app)/explore/page.tsx | 6 ++++--
.../onboarding/onboarding-client.tsx | 14 +++++++++++---
apps/web/src/components/layout/greeting.tsx | 12 +++++++++---
apps/web/src/lib/greeting-name.test.ts | 18 ++++++++++++++++++
apps/web/src/lib/greeting-name.ts | 16 ++++++++++++++++
7 files changed, 73 insertions(+), 11 deletions(-)
create mode 100644 apps/web/src/lib/greeting-name.test.ts
create mode 100644 apps/web/src/lib/greeting-name.ts
diff --git a/apps/web/src/actions/users.ts b/apps/web/src/actions/users.ts
index 0bd2b1e..398222f 100644
--- a/apps/web/src/actions/users.ts
+++ b/apps/web/src/actions/users.ts
@@ -4,6 +4,7 @@ import { db, eq, userInterests, userRegions, users } from "@the-forum/database";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { auth } from "~/auth";
+import { greetingName } from "~/lib/greeting-name";
import { uploadedImageUrlSchema } from "~/lib/s3";
import { campusRegionSchema, eventTagSchema, parseInput, uniqueEnumArray } from "~/lib/validation";
@@ -139,6 +140,16 @@ async function getCurrentUser() {
return createdUser;
}
+/**
+ * The name Home greets the viewer by (see ~/lib/greeting-name), read from the
+ * database: the session's name is captured at sign-in, so it still holds the
+ * NetID after onboarding sets a real display name.
+ */
+export async function getGreetingName(): Promise {
+ const user = await getCurrentUser();
+ return greetingName(user.displayName, user.netId);
+}
+
export async function getUserProfile(): Promise {
const user = await getCurrentUser();
diff --git a/apps/web/src/app/(app)/explore/explore-client.tsx b/apps/web/src/app/(app)/explore/explore-client.tsx
index c487ea7..5438327 100644
--- a/apps/web/src/app/(app)/explore/explore-client.tsx
+++ b/apps/web/src/app/(app)/explore/explore-client.tsx
@@ -47,7 +47,8 @@ interface ExploreClientProps {
savedEvents: FeedEvent[];
friendsEvents: FriendsEvent[];
initialSearch?: string;
- userName?: string;
+ /** First name to greet by, or null for a plain "Hello," (see ~/lib/greeting-name). */
+ greetingName: string | null;
userAvatarUrl?: string | null;
}
@@ -62,7 +63,7 @@ export function ExploreClient({
savedEvents,
friendsEvents,
initialSearch = "",
- userName = "there",
+ greetingName,
userAvatarUrl,
}: ExploreClientProps) {
/*
@@ -432,7 +433,7 @@ export function ExploreClient({
*/
return (
-
+
);
diff --git a/apps/web/src/app/(onboarding)/onboarding/onboarding-client.tsx b/apps/web/src/app/(onboarding)/onboarding/onboarding-client.tsx
index e3c757f..c8d7835 100644
--- a/apps/web/src/app/(onboarding)/onboarding/onboarding-client.tsx
+++ b/apps/web/src/app/(onboarding)/onboarding/onboarding-client.tsx
@@ -3,6 +3,7 @@
import { useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import { completeOnboarding } from "~/actions/users";
+import { greetingName } from "~/lib/greeting-name";
import { PRINCETON_MAJORS } from "~/lib/princeton-departments";
import {
CAMPUS_REGION_OPTIONS,
@@ -437,9 +438,16 @@ export function OnboardingClient({ displayName, netId, email }: OnboardingClient
{step === 4 && (
- You are all set
-
- {firstName.trim() || "there"} !
+ {/* Same rule as Home's greeting: no real name, no name at all. */}
+ {greetingName(firstName, netId) ? (
+ <>
+ You are all set
+
+ {greetingName(firstName, netId)} !
+ >
+ ) : (
+ "You are all set!"
+ )}
Your personalized Forum feed is ready.
diff --git a/apps/web/src/components/layout/greeting.tsx b/apps/web/src/components/layout/greeting.tsx
index a54f5d6..16ec747 100644
--- a/apps/web/src/components/layout/greeting.tsx
+++ b/apps/web/src/components/layout/greeting.tsx
@@ -3,14 +3,20 @@ import { formatGreetingDate } from "~/lib/date-format";
/**
* Home's greeting from the Figma: "Hello **Albert**," in large serif with the
* name in bold italic, and today's date (Princeton time) under an orange dot.
+ * With no real name yet (see ~/lib/greeting-name) it is just "Hello,".
* Only Home gets this; every other page uses the compact PageHeading.
*/
-export function Greeting({ name }: { name: string }) {
- const first = name.trim().split(/\s+/)[0] || "there";
+export function Greeting({ name }: { name: string | null }) {
return (
- Hello {first} ,
+ {name ? (
+ <>
+ Hello {name} ,
+ >
+ ) : (
+ "Hello,"
+ )}
diff --git a/apps/web/src/lib/greeting-name.test.ts b/apps/web/src/lib/greeting-name.test.ts
new file mode 100644
index 0000000..2fde5ac
--- /dev/null
+++ b/apps/web/src/lib/greeting-name.test.ts
@@ -0,0 +1,18 @@
+import { describe, expect, test } from "bun:test";
+import { greetingName } from "./greeting-name";
+
+describe("greetingName", () => {
+ test("first word of a real display name", () => {
+ expect(greetingName("Albert Einstein", "ae1234")).toBe("Albert");
+ expect(greetingName(" Albert ", "ae1234")).toBe("Albert");
+ });
+
+ test("no name when the display name is the NetID (any case) or empty", () => {
+ expect(greetingName("ia1234", "ia1234")).toBeNull();
+ expect(greetingName("IA1234", "ia1234")).toBeNull();
+ expect(greetingName("", "ia1234")).toBeNull();
+ expect(greetingName(" ", "ia1234")).toBeNull();
+ expect(greetingName(null, "ia1234")).toBeNull();
+ expect(greetingName(undefined, undefined)).toBeNull();
+ });
+});
diff --git a/apps/web/src/lib/greeting-name.ts b/apps/web/src/lib/greeting-name.ts
new file mode 100644
index 0000000..5519c7f
--- /dev/null
+++ b/apps/web/src/lib/greeting-name.ts
@@ -0,0 +1,16 @@
+/**
+ * The name a greeting addresses someone by: the first word of their display
+ * name — or nothing, when there is no real name yet. New accounts start with
+ * their NetID as the display name, and "Hello ia1234," reads like a bug, so a
+ * display name equal to the NetID counts as no name. Callers then render
+ * "Hello," with no name rather than a placeholder.
+ */
+export function greetingName(
+ displayName: string | null | undefined,
+ netId: string | null | undefined,
+): string | null {
+ const name = displayName?.trim() ?? "";
+ if (!name) return null;
+ if (netId && name.toLowerCase() === netId.trim().toLowerCase()) return null;
+ return name.split(/\s+/)[0] ?? null;
+}