diff --git a/backend/prisma/.screenshot-seed-manifest.cleaned-1785987939223.json b/backend/prisma/.screenshot-seed-manifest.cleaned-1785987939223.json new file mode 100644 index 0000000..8bfe21e --- /dev/null +++ b/backend/prisma/.screenshot-seed-manifest.cleaned-1785987939223.json @@ -0,0 +1,117 @@ +{ + "createdAt": "2026-07-12T01:14:00.250Z", + "attachEmail": "vanbibber.21@osu.edu", + "users": [ + "bccd538a-f94e-49dd-b70d-2ee77f9bbefc", + "dcef5eb8-edae-4b20-bddd-c8507a8b2019", + "5339bc5d-b23c-4f83-8dbd-895458fb19da", + "c5fea2e9-da50-48a4-aeda-44e28782184d", + "de6b5f00-5cc5-4cc1-9ba9-c844c0aed4cf", + "2813cf92-2a38-4374-b87f-14f648070dc0", + "22d18d04-20c2-41f4-9321-7e379ac7ccbc", + "ba378adf-00f9-4c5a-bf1e-4f7c634ce39d", + "352ba7a7-6b36-4e07-b394-cf80957efca2", + "399438df-5a7e-4f4f-9a46-1a45e1b12b94", + "45f8eff1-9788-45de-b40d-2dc6deba44bb", + "108fd329-861a-496d-afd2-68eb1e5acb4f", + "dbe3d155-7a3b-4cca-9241-4077d627311b", + "6c5ad932-2468-475c-8099-4354862b1781", + "7fc7e5e3-89e7-49a1-8096-12b75d3a6c16", + "e68ff3ef-57af-46fb-a64a-c02b335c70f6", + "43e8b093-d75d-46e4-844a-4fc03b6a32bb", + "ba979fe4-f47e-425a-b974-06fc47d20e54", + "17ff6194-a7d7-4e4c-b6e5-4bdc74575b38", + "0e499010-29d8-475a-b71f-02e0efd2ca98", + "a871bba0-6c88-4bc8-9b35-5631584b972c", + "b3adc1c0-cfbe-427c-bca3-dde71e160a0b", + "e5d2070c-5436-4377-b355-2fb8541586d6", + "3f229d71-9994-4528-b900-9da75b057e4b", + "31aee6f5-3084-4469-9ad0-0245b65246e3", + "1aa6c29e-83f8-44c4-8407-84895276039a", + "7efa486c-52ef-4e91-939f-b66b307dfed1", + "c6db849d-ec40-49ed-8b2e-54b6a30e4725", + "044555ee-7cfc-41ae-99af-cfa432e477fb", + "fa36009e-a7ad-4cfa-ab2e-f4cf42678344", + "3861151a-f1c3-4ce7-8eac-6d4dd346c0d7", + "530aaa3c-c9e2-410f-a448-3a3cb9d135e7", + "3057b4ef-7808-424d-816d-3f1399cbf443", + "8cd3d8f0-cfcb-4ab2-a90f-d03064f45ce7", + "a3a52387-022b-4fdc-bb9b-d648ab5d145f", + "2a042b72-2bed-4697-b2c7-cc0ce9f48503", + "4039666f-d23e-443c-ad4e-7ac56e6c8054", + "e676e794-6e56-4b56-b87e-39bd6eb74f0f", + "43c80179-4c0e-4c01-bd53-c86aa1f5f6ea", + "abb21da5-a5bb-4819-98a6-1ce599ca0b27", + "56ced78f-4d95-4d9d-aca7-ed080025df44", + "44147e7e-4b7b-402b-9b77-a7a6a97f6bc2", + "7bd3f3a1-3599-4b88-8bfb-d7c07cd859ec", + "7507f997-7357-4e65-9724-c5367be50050", + "a93256cc-2bf9-4331-a9fb-55000641b90b", + "d0f03620-5cd2-4b2c-8c48-58c4186a7e7d", + "b2a73912-d096-4c35-91b5-200761ffae5c", + "9691a0f5-3edf-4a48-9c82-9776ffdf3c97", + "b75cc563-ed25-4750-a6eb-34d4b964fc56", + "be101b0b-632e-4a7f-a1bf-b7de254c3f48" + ], + "activities": [ + "9ef5c02c-8713-4d69-9eb3-068ab446a7b5", + "1f346a67-3966-428d-a1eb-52a3bdeac723", + "9db9713c-c654-43a3-9184-21581d4b3ef0", + "c8456869-604f-4600-b77d-17fac6664092", + "bdc8b24c-3589-4d3e-a904-eb6670c0c5be", + "83d66357-2933-4194-8f02-a5a28ba7a3d5" + ], + "pods": [ + "fcc5597e-23f7-4afa-b301-7a4930853ba4", + "d9f11ca3-cb5b-4273-83f4-ff2c6fcbb7fa", + "dfc8d7fa-7bd0-4804-8881-5f0dd1e5cb49", + "2fa836c1-646f-4425-874a-30661bf34ecd", + "cc586d2c-2127-4885-b961-0896d90d3026", + "1ea0d7a3-3562-487c-ba88-b6ac85272b6e", + "df4c5bbc-3314-4d6a-86c6-a6e1ae4e771e", + "870ec9e3-ae0b-46c4-b88d-509f26a5e145", + "b830b564-ae53-45aa-b94e-6c0d74601513", + "2d468e0a-7933-4fd5-bd73-4ba1cc27bfb2", + "3641cf91-bfe4-476d-b383-c277bb30b231", + "87841674-7e41-49ad-b3be-764051f6ffee", + "84971a31-7bde-409c-97c2-df719135fb64" + ], + "clubs": [ + "89c3ed4b-fa65-4d95-97ba-9c4dc79844a7", + "7c7bc257-90bd-46fb-a49a-ceb979f5b963", + "a11dad0f-b33c-453c-b0ee-941965039b67", + "0aa5797a-c0f1-4e6d-9f34-ad190c6d2bc3", + "0e3f3c97-ed02-4056-b49c-f5639d5a6c1b", + "3ae821dd-c713-4a55-85d0-9d1238e449c8", + "5c4f4a48-b3aa-4077-9765-086eedada659", + "36563e48-5b51-4bef-9374-e3506c156c51", + "265769a5-d766-498c-a38c-241ec6a05b4c", + "90752a25-6033-4ef8-b6f5-c0f5d4721d59" + ], + "dmThreads": [ + "aa591d64-ee1a-4283-aaec-abcd67707dd4", + "d83028d5-d92b-4d80-a1f5-1da2d5774ba2" + ], + "friendships": [ + "af02dbdd-a375-4d16-892c-2003d768dd83", + "6b58d99d-2c8f-4a16-a049-96cb72a63dad", + "0a3693f9-edde-46c1-bd39-66aa412ce33a", + "72ac765d-c96e-4116-afdd-76566d8b8ddb", + "150704e0-b890-499b-afe3-12f97b2b2b4f", + "11c8d7ce-2c34-4a1e-9037-26cac93fa754", + "fe249dea-ab32-4e41-858d-b39a88dcf0c1", + "8f4138bf-b7db-4ef3-b59f-f9bad7db0c25", + "5891a963-802a-42c0-9ed5-47450807a123", + "7f203fdd-350f-4263-b9a7-e3acbdf3f206", + "e0e628ac-96ec-4201-8924-f1f85df36eca", + "d9a237f2-b6e7-4d4f-920f-1e1c9b121fea", + "6abef7b7-d8fd-4c1e-a094-108d5274766d", + "16cfe4b6-487d-4494-a4bd-357c993adce3", + "c9eb0679-2a98-4f78-8a4a-a94afda97bed", + "c462ba2d-0f85-4acb-81ba-a839571f186d", + "c86d5e13-7a76-450b-b1be-c5efa7d21ac8", + "d6ec8c1d-81d9-40ce-84ce-bf577cf81bbc", + "bf12417a-8547-4d34-ac52-c576c0ed1010" + ], + "friendRequests": [] +} \ No newline at end of file diff --git a/backend/prisma/.screenshot-seed-manifest.cleaned-1785988558732.json b/backend/prisma/.screenshot-seed-manifest.cleaned-1785988558732.json new file mode 100644 index 0000000..f8ff1d3 --- /dev/null +++ b/backend/prisma/.screenshot-seed-manifest.cleaned-1785988558732.json @@ -0,0 +1,110 @@ +{ + "createdAt": "2026-08-06T03:45:39.899Z", + "attachEmail": "vanbibber.21@osu.edu", + "users": [ + "223592ed-792c-4d06-ad71-b2522512dba5", + "9749cf7a-53b9-4c1e-bddf-effdb831c5f2", + "195563e4-6453-4b75-96e8-5904567157f3", + "79f631c1-64c3-4708-8e67-1e6ea656385d", + "d427276e-015a-4f38-8521-5148daf82c0e", + "05220e64-e617-4a81-a14f-4f9cdaca0b09", + "a3989c2a-1ac6-477a-ba72-c01aa3347faf", + "3ec519df-f14d-4e13-84cc-bee531185d69", + "9fee6d3c-e8bd-4e7c-a8bc-5ae21ff49738", + "11c6dde4-ccb1-4601-84fa-6280308c13a7", + "18ee4e71-39d5-4095-9c72-b692a29b4ec0", + "6a2fcb5c-32c9-4230-86bb-d821a32f3c17", + "2a76e004-4f10-4585-a941-bc42655145a3", + "63e772a4-297e-4d57-94e0-08040510de95", + "2a31e652-8ece-4b3b-a2f7-d656e5f602a7", + "b9dd4f63-d86f-4075-8d99-b2168cc26207", + "206f346e-0430-4810-b0e3-3996e94214f3", + "77dbeb4b-e212-447c-8f5a-268fe2eb3e99", + "4c6148fa-0da6-4560-88b3-a4d8c7c954fc", + "a73fa96e-5f99-42e2-a5a0-09f96a525545", + "927477df-171e-4fef-b857-9ef7eb32b69f", + "52ec357a-3247-44c1-99e0-5a60ca0a49d0", + "e08d6784-4436-49c9-8c21-d156714a1ea5", + "5194099e-b75a-4a6f-b7f2-3bf8b782da64", + "0a7dc922-8dd1-44e5-9924-4b1cc2e2f4f9", + "71d7cd5c-738f-44fd-97e9-7d75e828b3a2", + "34a99c5b-1ec4-49dc-b7c7-4484f81740d4", + "4343a0e9-7515-4c93-b205-42ec81e4adc5", + "442c12a8-2dbd-49b6-b091-f0ee65430e68", + "6101ef52-864f-48e7-901a-65f9e104b2a7", + "09605f98-4187-4c74-9add-2b17c1b82eee", + "580c8b21-6f5d-46ae-8827-fb0f45b354e9", + "335484a8-f50a-4027-82f1-dfa0ffd902a1", + "2f18e22b-20c0-4d0e-a726-be9f2efc78b4", + "162fd80e-221e-4352-9dd4-6001c4a54a52", + "f1c755a2-5338-4e9d-b988-de3bf31ae010", + "2541a50b-c48f-4742-b62c-e50095f55754", + "2cb140ae-e732-4242-88c3-34fea44c21d5", + "d0b3f1be-7861-413e-9bfa-462b7d6513bf", + "f974d3df-9770-4198-bba8-caee5303b644", + "0f001b79-eb1f-435e-ac16-7540277d030b", + "3174696b-d792-496d-a375-010194491822", + "d5c9688e-df86-4619-83c3-d8e3f1a092e5", + "bd512a38-66a1-4843-8681-ec695fc67d62", + "b6f5bf1c-d7ff-447f-a230-f691ff3b69b3", + "b02b0fab-5050-4844-b0fe-10e5ae6bebff", + "055b75a8-8666-451e-8bfe-7dff63e1dcc7", + "115cc4be-ac8e-4aef-a5bc-24c58b5685e7", + "6eba9ec9-1652-4f31-af3b-e7dd07461f11", + "329217ff-22f8-4ec4-bf8b-cc25d8eef723" + ], + "activities": [], + "pods": [ + "4aec8b25-5b33-414c-bcdb-55448fdcab01", + "15eb4709-fbb9-4ff7-a11b-7ce045fb63af", + "9a718d78-d52a-46ed-9718-4ae8c7cfc77f", + "b497fa9e-0642-447f-ba1f-1c30bf25a1f8", + "aaaa861d-2ff3-4822-960b-41bab7b73faa", + "4a8baddc-6f2c-48af-8a99-f02fd3511481", + "644894ec-4394-4c7d-8184-23c76289d4d1", + "73c84479-0304-4240-b337-b9b795b3b39a", + "48ee0331-dc2f-4421-92dd-bb1148f92a7b", + "e1aaba53-0910-4ba8-ba2e-3e2e2c524cdc", + "31e3ff4d-e079-496e-9a84-f301bcfabf05", + "e6585b8c-fbc0-4a54-88b4-1bdf84d94975", + "5269cd52-4869-4bf0-816c-2f7f20be263d" + ], + "clubs": [ + "7d01241f-1460-4858-9c38-7c024d1f7181", + "3c4f0314-cbe1-4756-bfef-f3e133b1ceba", + "1448044c-15d3-475b-be51-4e42cab31dd9", + "8f21bae2-4d4a-4a62-ba02-ec6935170655", + "0676d9f4-7fef-48c0-a22a-d3dcb2ad538e", + "caea9e14-aaa6-40bf-8c87-a9746a0cd67d", + "73953454-7b39-4b70-9809-785997f45f7a", + "8c7ec9ad-6af1-4735-83ab-e8d7962656b0", + "a76ae894-2212-41f1-9b2d-cd53a7c198c8", + "032db24d-3c28-4665-83a5-8c3671e73bfe" + ], + "dmThreads": [ + "838e713d-10fb-4ee1-b7f6-3e78568195c4", + "31a5a31d-4f83-435f-8bf1-8bcab626f48c" + ], + "friendships": [ + "ac2b71eb-f772-4f84-b707-c450962288d6", + "7b73a823-6204-42c2-8970-91c19bfb8b63", + "6e1dc998-5c7f-48a1-8f50-dd4c377bfa44", + "8611708f-5cc3-4c1d-9ce8-773ed2088fca", + "0c69884f-804f-40a8-a211-6fdce3045226", + "93f5fc64-ef31-4cb6-9b02-207606f0dd82", + "4965ff4d-cb80-4edb-ad8d-302cd719e5a3", + "d25be7f6-2271-4dab-91b9-702d265ce12a", + "f111bf93-796b-4f21-9450-b61f6204a11e", + "5625a612-581f-4169-a95a-bd77b364ef88", + "f3a9997f-4047-4a0e-af7a-29537655fa67", + "2aa2dbfe-5bfb-4ba8-a70a-fb999eb36bbc", + "7e46b6a9-0dea-4f91-a19a-58363da56200", + "b138e88b-1365-45e4-8f73-230c3ae68b63", + "4108537d-eba0-4ddc-9ba9-492a53baa722", + "781157db-a989-49c2-b5d8-cd81968efd58", + "cd78fe6d-9963-4e44-ae12-9af64a185b27", + "962fe206-0d74-4594-84c3-c25a3cbe6c27", + "b3020b99-4955-43a6-a87a-06f98654e31e" + ], + "friendRequests": [] +} \ No newline at end of file diff --git a/backend/prisma/.screenshot-seed-manifest.cleaned-1785990914730.json b/backend/prisma/.screenshot-seed-manifest.cleaned-1785990914730.json new file mode 100644 index 0000000..bfd3acc --- /dev/null +++ b/backend/prisma/.screenshot-seed-manifest.cleaned-1785990914730.json @@ -0,0 +1,110 @@ +{ + "createdAt": "2026-08-06T03:55:59.442Z", + "attachEmail": "vanbibber.21@osu.edu", + "users": [ + "56300a83-b98f-4229-b62c-93fe2babc477", + "6c508b09-6930-4df4-8ed3-83b2a9061010", + "5ea229a8-31a6-4560-9f81-f9d8f9075210", + "d7d25d45-78f5-42d5-b3c4-dc35803d3466", + "fd51d2c1-6542-49df-90c0-91f52ebacac4", + "0c177e72-00f6-4875-9167-db38bec4cb7f", + "6e525deb-6ab7-4817-a725-4655431f9a72", + "fdff7c12-a1ad-430f-82e3-7d232985e048", + "da24abe3-bd90-4629-857d-1a1db9d5e560", + "cba20ae0-feec-4e72-93e8-64fd7d064a11", + "e21514e5-04d2-4587-b42d-31923f95b9bc", + "2acecdc3-acd0-4057-91e2-1145099e594c", + "1e594d2a-65ae-41dc-9364-b477c3550754", + "4d9bfe65-a6e7-4b81-8063-d094a04006a7", + "08384752-5a94-43e5-8a6c-9011e93ff439", + "e3003c11-a54a-4c26-9374-096cb12c6fc3", + "fcf83fb4-e59f-41a3-a3b3-515012805856", + "2be53de3-328b-4c97-bbfa-3df00b33e1b5", + "00218a77-50df-4fd2-840a-5e737cc31cc8", + "f3618299-ed60-4a65-9c6e-b7c4d1f81b34", + "f63f81af-e375-4395-af8a-63dce60db2c8", + "13265083-4bdc-43ad-97d5-4ea3fa7dd0db", + "923ebd67-0051-4c02-bb25-69c437aa2a10", + "5c51c05d-bd51-4023-bf82-8efdff93ff3e", + "59941682-d8a4-4faf-a700-8e35e9a00228", + "27ad4cd7-2984-4a05-aa4a-b90c844508d0", + "af8dadda-4b32-4937-825f-7293527a9f2c", + "703edf32-31c8-474c-8cc3-b611e69a50a4", + "8092cacb-7031-4093-8dac-f1ebc759a59e", + "0a6a6419-b2c4-46be-9177-db515ea56427", + "b2061290-89cf-4526-b6a4-7292f4ae2763", + "cf4b2dcc-fa3d-401a-9353-0ee47ffeac0b", + "40743d16-00d9-4261-b63d-e3860d91c803", + "3d9d81fb-3ca1-4cdd-b5cc-82b135a276e5", + "e29446a8-921a-41e4-bc41-24139dd3bcf8", + "f08fcfba-2b23-480b-a707-e4b06034feb5", + "e48c77ec-64dd-41a1-82ac-0b8b02308e0c", + "f936e7a2-0cff-4c75-a8dc-60240202f206", + "8842aee2-2669-4b09-bf5b-549ef88a2b5c", + "c758ffa6-4874-4410-86b0-80f9c2289703", + "a023dc6f-fe74-4ef2-a932-3800dc6a2271", + "b046d4f3-ac14-413b-a91d-42a7a68e9d2b", + "6f3ba114-afc7-4448-8e4d-1420e92c530d", + "7ce810ea-98f0-4dd5-a7fc-f92ffe21aea0", + "1e251a83-f1ae-43af-b4ff-8ef95d034dc5", + "b9748494-fe2e-4aab-9b95-451a03efcbfe", + "8b766bd6-7b92-4775-8579-f51070cb118b", + "5f94ed85-135d-4866-9691-16bf1a815a33", + "d9b8b97a-55f5-4bff-b80b-c531bbda9414", + "e242593e-5f62-4910-bfb9-a45eeb9172e7" + ], + "activities": [], + "pods": [ + "dc2fc6f4-5355-4757-aa93-7fafd27397ce", + "cb4cc399-3b21-425f-b5a0-73c2a5124357", + "65176b30-cdc2-494c-b8c7-1124d9d3152b", + "433e8b35-9e12-461d-ab55-1a686ec874f1", + "13dfb240-be65-40ba-b258-e15357524cc2", + "abcb6b66-2424-43f9-8ace-85d62ad794ad", + "aa755a4f-24c2-4e07-a825-834912bf7f2b", + "a542cafd-9c56-4b2a-af00-9ce8ab183da5", + "b7e2efff-cf67-45c4-9e40-7da0d8be3865", + "eb2fe7c1-f4b9-4f25-863e-7f5e25298e23", + "03fb610c-a53a-4f15-8930-aed87885140c", + "91121c7c-8cbf-49e4-90af-e8f5968224af", + "22dda1ee-c02c-45a5-9e1f-31c1e4b1fe49" + ], + "clubs": [ + "c7d51b86-d5cc-43ff-b8f7-fc9cce873986", + "72231f73-bc1e-477f-ba6e-90dcf7c4c417", + "49a82db1-5ad1-4254-a075-6cf6664f6598", + "91000922-f2d9-463b-8977-af07bbbdcb2c", + "33a904ca-77c1-4c41-8cc7-8cd285724d4b", + "d2e80aea-98f5-4eb7-a659-29501fcf68ec", + "bfa9f7b6-6373-4996-b248-f614af9db328", + "4ecf0502-46bb-47aa-bfe3-e4b76a3e8f12", + "9e210005-2bc1-4d1c-ba32-303661cd86c0", + "0a9fb4f5-e587-45b6-b790-92f8c2d07056" + ], + "dmThreads": [ + "554fe188-3f03-4cbf-bc6b-18a65ea24a09", + "51d0a29e-45e6-45ce-916c-e4d14c646f86" + ], + "friendships": [ + "6e531878-e15f-409f-a342-b0256765a7b7", + "2d179bd9-c6f9-4dcc-838f-8b199d50949b", + "eb64a4cc-e82b-4f4b-971f-586bccc7bf13", + "001be96b-b18a-40e7-a5fc-93288e9e9cc5", + "1e0bd03d-b815-4617-a128-c75c2f521d3a", + "11be89f5-2c88-427c-b376-cd7ef68e2c3f", + "537809c9-57f0-4181-8899-d4a8982dedc4", + "e1bd76d5-201a-490f-9ef7-2a357b928d41", + "260ba96f-cf61-4902-bcae-ee9362beaa9c", + "d79f851a-c5f4-4d06-a462-ffe6176444d1", + "8a443bdb-e777-433d-98cc-5bdbbfd1e61c", + "ae638b26-8c30-4395-a470-bdb1df58b0dc", + "cdd18fb9-2a66-45d7-bf13-8a9b732483db", + "257b6b32-b64d-4173-a88f-b783a5a5a197", + "96a3b28e-e1a3-4424-93da-9f7c26be19db", + "0f059f16-6673-4066-b094-362237b53d83", + "3a37444c-d1ba-4d4a-9874-7206749ac5c4", + "68be0c27-6df2-4cc7-a7e8-7433b8fe4b19", + "1f400abc-60d8-4b05-a259-1f6104e34a1c" + ], + "friendRequests": [] +} \ No newline at end of file diff --git a/backend/prisma/.screenshot-seed-manifest.json b/backend/prisma/.screenshot-seed-manifest.json index 8bfe21e..57bea02 100644 --- a/backend/prisma/.screenshot-seed-manifest.json +++ b/backend/prisma/.screenshot-seed-manifest.json @@ -1,117 +1,110 @@ { - "createdAt": "2026-07-12T01:14:00.250Z", + "createdAt": "2026-08-06T04:35:15.448Z", "attachEmail": "vanbibber.21@osu.edu", "users": [ - "bccd538a-f94e-49dd-b70d-2ee77f9bbefc", - "dcef5eb8-edae-4b20-bddd-c8507a8b2019", - "5339bc5d-b23c-4f83-8dbd-895458fb19da", - "c5fea2e9-da50-48a4-aeda-44e28782184d", - "de6b5f00-5cc5-4cc1-9ba9-c844c0aed4cf", - "2813cf92-2a38-4374-b87f-14f648070dc0", - "22d18d04-20c2-41f4-9321-7e379ac7ccbc", - "ba378adf-00f9-4c5a-bf1e-4f7c634ce39d", - "352ba7a7-6b36-4e07-b394-cf80957efca2", - "399438df-5a7e-4f4f-9a46-1a45e1b12b94", - "45f8eff1-9788-45de-b40d-2dc6deba44bb", - "108fd329-861a-496d-afd2-68eb1e5acb4f", - "dbe3d155-7a3b-4cca-9241-4077d627311b", - "6c5ad932-2468-475c-8099-4354862b1781", - "7fc7e5e3-89e7-49a1-8096-12b75d3a6c16", - "e68ff3ef-57af-46fb-a64a-c02b335c70f6", - "43e8b093-d75d-46e4-844a-4fc03b6a32bb", - "ba979fe4-f47e-425a-b974-06fc47d20e54", - "17ff6194-a7d7-4e4c-b6e5-4bdc74575b38", - "0e499010-29d8-475a-b71f-02e0efd2ca98", - "a871bba0-6c88-4bc8-9b35-5631584b972c", - "b3adc1c0-cfbe-427c-bca3-dde71e160a0b", - "e5d2070c-5436-4377-b355-2fb8541586d6", - "3f229d71-9994-4528-b900-9da75b057e4b", - "31aee6f5-3084-4469-9ad0-0245b65246e3", - "1aa6c29e-83f8-44c4-8407-84895276039a", - "7efa486c-52ef-4e91-939f-b66b307dfed1", - "c6db849d-ec40-49ed-8b2e-54b6a30e4725", - "044555ee-7cfc-41ae-99af-cfa432e477fb", - "fa36009e-a7ad-4cfa-ab2e-f4cf42678344", - "3861151a-f1c3-4ce7-8eac-6d4dd346c0d7", - "530aaa3c-c9e2-410f-a448-3a3cb9d135e7", - "3057b4ef-7808-424d-816d-3f1399cbf443", - "8cd3d8f0-cfcb-4ab2-a90f-d03064f45ce7", - "a3a52387-022b-4fdc-bb9b-d648ab5d145f", - "2a042b72-2bed-4697-b2c7-cc0ce9f48503", - "4039666f-d23e-443c-ad4e-7ac56e6c8054", - "e676e794-6e56-4b56-b87e-39bd6eb74f0f", - "43c80179-4c0e-4c01-bd53-c86aa1f5f6ea", - "abb21da5-a5bb-4819-98a6-1ce599ca0b27", - "56ced78f-4d95-4d9d-aca7-ed080025df44", - "44147e7e-4b7b-402b-9b77-a7a6a97f6bc2", - "7bd3f3a1-3599-4b88-8bfb-d7c07cd859ec", - "7507f997-7357-4e65-9724-c5367be50050", - "a93256cc-2bf9-4331-a9fb-55000641b90b", - "d0f03620-5cd2-4b2c-8c48-58c4186a7e7d", - "b2a73912-d096-4c35-91b5-200761ffae5c", - "9691a0f5-3edf-4a48-9c82-9776ffdf3c97", - "b75cc563-ed25-4750-a6eb-34d4b964fc56", - "be101b0b-632e-4a7f-a1bf-b7de254c3f48" - ], - "activities": [ - "9ef5c02c-8713-4d69-9eb3-068ab446a7b5", - "1f346a67-3966-428d-a1eb-52a3bdeac723", - "9db9713c-c654-43a3-9184-21581d4b3ef0", - "c8456869-604f-4600-b77d-17fac6664092", - "bdc8b24c-3589-4d3e-a904-eb6670c0c5be", - "83d66357-2933-4194-8f02-a5a28ba7a3d5" + "8dc70cf3-373c-4b3b-9856-6ed72ae20673", + "b50200b0-42af-424c-9edd-9513ceced898", + "126f8ac6-470d-4c0d-b358-e47222a40a36", + "72dc110a-951e-415e-b91c-f1a25ae93f35", + "aa21f963-bc14-429d-ac4c-e31ac1d115d9", + "e172b71e-9033-47ca-82b5-199bf8483037", + "6f246dd4-4f0e-4739-9002-648a8ab1da55", + "56dafb8e-aebe-4d57-960d-d52aabdd12e6", + "c474b19b-e9cc-4341-b18a-5738a80ee91b", + "6cd28744-1632-4f26-9387-8c835c8722cc", + "91aae5e0-711c-49e9-af76-6c8decf63f7f", + "8a61e1cb-7361-427d-ac34-85d9fadfbe89", + "b6b0d47a-3b3c-4b20-8d75-f55c4250a2f5", + "a8093a6b-fb03-4f32-b60c-87fb8ab9709d", + "fbfc9d9f-9aeb-4f3d-86e3-536d24a33684", + "7f051658-e7a1-4817-bb38-9f3ae2f33c11", + "a35c04dd-5dc8-445c-b847-df111dcb554b", + "c8334441-4169-404c-ab57-d6ab7203558e", + "49c08789-8c9c-4804-b120-69797e637231", + "ccdd7925-2c6a-4594-8d01-9124b5fad2eb", + "33136aa6-8ebf-47cc-b296-a2edc9999e34", + "09b4ded6-5acc-4a36-83ef-5321daea137e", + "5006c6ce-bec9-4fd0-831d-d7c7b285bf5c", + "04e6de33-72ea-4e4c-a075-189fbd997fd7", + "7baae2b4-b0d1-48d2-9599-71da00ea5d28", + "985732ca-df58-49a5-a684-7cd689433ba8", + "c4353f84-79cd-4ec1-a062-4cdf26fc1511", + "906baf2f-7de8-44a0-9a68-021bdab64c8f", + "afdbf23d-e502-4588-b4f8-f79a8ecfbfc6", + "f1b35aed-e8e1-41e4-982c-2c994f441075", + "a29cee50-e50d-4f02-a79a-ce8885b2a0b6", + "1e4e3dae-4b6a-4f49-9406-e24e946477f8", + "7bf406dd-8573-4425-b737-05d2cb710956", + "f41fc799-c4c6-461a-a1d6-0fef078805df", + "67ff2f40-b3af-4002-90a4-ad81272adb8a", + "4db03314-22c1-44bf-a43f-8e2cf07ac149", + "37251c4c-a226-4b33-ab20-716d4e1f7c72", + "bcf83058-68ae-47fc-82ee-f15871ef2636", + "541969dd-ba18-4d12-9fc6-2b6632823ab2", + "ff7cb350-ed58-4e48-bdb8-afaa391019b8", + "fa286f1e-a8a3-4faa-8516-f6e4ee971927", + "dd412556-fd4c-4cf7-bbc2-37c118223556", + "4944da8f-332c-4ce9-a932-6877f046c5cc", + "687cf0b0-a0df-4216-89a2-9aeb73616346", + "1b1ac1e3-e788-49fe-8e05-8e6bd704da5b", + "1a7895c4-cea5-4a3c-a5c4-663ac36c30ff", + "56c6b263-5f04-4fa6-8362-6edad66fca64", + "ae6e4eda-32ab-436a-8660-c4580dd16aaf", + "002dda82-20fb-4843-af7c-6e73b9f5e1b6", + "83cdeaf8-035c-45a7-894d-1b4f103eba22" ], + "activities": [], "pods": [ - "fcc5597e-23f7-4afa-b301-7a4930853ba4", - "d9f11ca3-cb5b-4273-83f4-ff2c6fcbb7fa", - "dfc8d7fa-7bd0-4804-8881-5f0dd1e5cb49", - "2fa836c1-646f-4425-874a-30661bf34ecd", - "cc586d2c-2127-4885-b961-0896d90d3026", - "1ea0d7a3-3562-487c-ba88-b6ac85272b6e", - "df4c5bbc-3314-4d6a-86c6-a6e1ae4e771e", - "870ec9e3-ae0b-46c4-b88d-509f26a5e145", - "b830b564-ae53-45aa-b94e-6c0d74601513", - "2d468e0a-7933-4fd5-bd73-4ba1cc27bfb2", - "3641cf91-bfe4-476d-b383-c277bb30b231", - "87841674-7e41-49ad-b3be-764051f6ffee", - "84971a31-7bde-409c-97c2-df719135fb64" + "5d4762ba-0ca4-4edf-b40b-ef9215a5b01b", + "207a1674-a547-439c-ad62-12e828093fc2", + "e9bfa229-9a9b-44cd-8dfd-287d7da60272", + "68b0b926-7caa-40ee-8acf-540752dd71b6", + "6ffbf5be-3267-44b8-89d3-ce9ccdce7ece", + "457da57a-6fbd-4840-b56e-146bb1068f8c", + "ebf20956-6daf-42c3-ada6-7a024dd13e23", + "c7841ded-16ec-43a4-806e-51469757e6af", + "7b4bf3b2-61ad-46f4-b738-2aa6db993b1d", + "f2b8cd5b-1219-4e93-9cd5-a3792fab10c2", + "0a359d8f-265e-418b-99ab-72b294a42b73", + "d4052243-4d8c-4657-8a1f-949ea3d3d11b", + "75b765ee-3254-424b-aec3-5076b00b2ca4" ], "clubs": [ - "89c3ed4b-fa65-4d95-97ba-9c4dc79844a7", - "7c7bc257-90bd-46fb-a49a-ceb979f5b963", - "a11dad0f-b33c-453c-b0ee-941965039b67", - "0aa5797a-c0f1-4e6d-9f34-ad190c6d2bc3", - "0e3f3c97-ed02-4056-b49c-f5639d5a6c1b", - "3ae821dd-c713-4a55-85d0-9d1238e449c8", - "5c4f4a48-b3aa-4077-9765-086eedada659", - "36563e48-5b51-4bef-9374-e3506c156c51", - "265769a5-d766-498c-a38c-241ec6a05b4c", - "90752a25-6033-4ef8-b6f5-c0f5d4721d59" + "02183d33-94ac-4c15-81b9-9cee43c010c3", + "ae0500bf-fa4d-41ad-89b1-af5704aabb23", + "e1c8f502-7fa7-4500-b8f5-9322302be34e", + "1adca9b1-1c99-469e-a19c-baf9a17d27cd", + "14ab8c13-5b6e-4cf5-915c-4fdc30e559df", + "87e760f6-946d-4abe-ab91-0b05c6d4c684", + "415007e3-9b46-4b26-9229-ed055987d3b4", + "45479e87-a1a4-4cc3-8bb0-06940aad2c2c", + "adc44fdf-8c98-4038-85be-924c14c8d1d4", + "4d08cbe1-76e2-4e64-a904-2d3422a42e68" ], "dmThreads": [ - "aa591d64-ee1a-4283-aaec-abcd67707dd4", - "d83028d5-d92b-4d80-a1f5-1da2d5774ba2" + "69567524-0d74-4bf4-bf06-d548b2b17bee", + "ebc4d090-7ade-4169-8bd2-af3a26247ae7" ], "friendships": [ - "af02dbdd-a375-4d16-892c-2003d768dd83", - "6b58d99d-2c8f-4a16-a049-96cb72a63dad", - "0a3693f9-edde-46c1-bd39-66aa412ce33a", - "72ac765d-c96e-4116-afdd-76566d8b8ddb", - "150704e0-b890-499b-afe3-12f97b2b2b4f", - "11c8d7ce-2c34-4a1e-9037-26cac93fa754", - "fe249dea-ab32-4e41-858d-b39a88dcf0c1", - "8f4138bf-b7db-4ef3-b59f-f9bad7db0c25", - "5891a963-802a-42c0-9ed5-47450807a123", - "7f203fdd-350f-4263-b9a7-e3acbdf3f206", - "e0e628ac-96ec-4201-8924-f1f85df36eca", - "d9a237f2-b6e7-4d4f-920f-1e1c9b121fea", - "6abef7b7-d8fd-4c1e-a094-108d5274766d", - "16cfe4b6-487d-4494-a4bd-357c993adce3", - "c9eb0679-2a98-4f78-8a4a-a94afda97bed", - "c462ba2d-0f85-4acb-81ba-a839571f186d", - "c86d5e13-7a76-450b-b1be-c5efa7d21ac8", - "d6ec8c1d-81d9-40ce-84ce-bf577cf81bbc", - "bf12417a-8547-4d34-ac52-c576c0ed1010" + "bbf8e092-32d2-4319-8d00-d3d7c72c2a7e", + "6cb353eb-aa2a-4211-97ee-035f356af406", + "727b7349-56da-4b35-8e33-bac8703ed4ad", + "807426f9-a0be-4f92-aab8-df31d81e5e24", + "8ed7efff-ed81-4a14-9ab6-77373c14b8f5", + "960054a6-1bed-4dc9-8bd4-c49bce3cd9ad", + "d4f049d3-0c05-4296-bd02-3b532db89dfc", + "393c7dcf-ad48-4aa7-aeab-f6ff4f388e1e", + "d14e129f-391b-4219-8645-9fbbc6f82880", + "fbd3db7d-d8d9-46a5-a380-d5c84fe2cbf8", + "e5513970-0024-49b1-b323-679d0ea7e5b5", + "0423b5ab-7332-4b01-82d7-98da5719ef2f", + "b9eb105f-4a8b-441e-a939-78f17ac6d49c", + "638362d2-4976-4085-b9be-885016286391", + "e493afa8-78ab-4767-bb50-40f9aed0fcbe", + "43ef627b-03b0-4ebb-9338-5afe913f9ca0", + "9a762187-d607-43d4-be53-3c8666857a65", + "86d1fe5a-c1be-4bf6-8ba1-891ade1e68e4", + "58b70494-84d2-412f-9095-3d9c29cf5c15" ], "friendRequests": [] } \ No newline at end of file diff --git a/backend/prisma/seed-screenshots.ts b/backend/prisma/seed-screenshots.ts index bc684eb..83ab51e 100644 --- a/backend/prisma/seed-screenshots.ts +++ b/backend/prisma/seed-screenshots.ts @@ -32,10 +32,41 @@ import { getCoordinatesForLocation } from '../src/config/locations'; import * as path from 'path'; import { CURRENT_TERMS_VERSION } from '../src/config/legal'; +// Everything in Oval is Columbus time, and this script runs standalone (not +// through server.ts, which forces the same thing). Must be set before any +// local-time Date math below. +process.env.TZ = process.env.TZ || 'America/New_York'; + const prisma = new PrismaClient(); const MANIFEST_PATH = path.join(__dirname, '.screenshot-seed-manifest.json'); const now = Date.now(); -const hrs = (n: number) => new Date(now + n * 3600 * 1000); + +/** + * Columbus-local clock time: `days` from today at exactly hh:mm. + * + * Screenshot content is pinned to whole clock times (7:00, 18:30) rather than + * now-plus-N-hours, which inherited the current minute and produced times like + * "10:47 PM" in the UI. + */ +function atLocal(days: number, hour: number, minute = 0): Date { + const d = new Date(); + d.setDate(d.getDate() + days); + d.setHours(hour, minute, 0, 0); + return d; +} + +/** + * Same, but rolled forward whole days until it is in the future — keeping the + * clock time intact. Pod feeds filter on `meetupTime > now`, so a pod whose + * slot has already passed today would vanish from the app entirely. Club + * meetings do NOT need this: GET /clubs/today selects the whole local day, so + * an earlier-this-evening meeting still shows under "Tonight on campus". + */ +function upcomingAtLocal(days: number, hour: number, minute = 0): Date { + const d = atLocal(days, hour, minute); + while (d.getTime() <= Date.now()) d.setDate(d.getDate() + 1); + return d; +} const manifest = { createdAt: new Date().toISOString(), @@ -90,6 +121,19 @@ const INTEREST_POOL = ['Coffee', 'Running', 'Music', 'Coding', 'Soccer', 'Photog const POOL_SIZE = 50; +/** Title of the always-tonight showcase meeting; also its rerun dedupe key. */ +const TONIGHT_MEETING_TITLE = 'Build Night'; + +/** + * Clock time for tonight's showcase meeting, Columbus local. + * + * 17 is the earliest hour that still reads "Tonight" — both the clubs header + * (`meetingHour >= 17`) and anchorWindowLabel (`isAfternoon = hour < 17`) flip + * at 5pm exactly. Anything earlier renders as "This afternoon". + */ +const TONIGHT_MEETING_HOUR = 17; +const TONIGHT_MEETING_MINUTE = 0; + const SCREENSHOT_ACTIVITY_KEYS = [ 'frisbee', 'trivia', @@ -195,20 +239,22 @@ async function main() { const ovalLat = 39.999; const ovalLng = -83.0129; // Spread across the next 7 days so the upcoming-week view looks alive. + // `day` is days from today, `at` is a fixed Columbus clock time — anything + // already past today rolls to the same time tomorrow (see upcomingAtLocal). const podSpecs = [ - { actIdx: 1, location: 'The Lounge – High Street', inHours: 18, size: 4, max: 4, chat: true }, - { actIdx: 2, location: 'Thompson Library – 11th Floor', inHours: 4, size: 3, max: 5 }, - { actIdx: 4, location: 'The Oval', inHours: 8, size: 2, max: 4 }, - { actIdx: 0, location: 'The Oval – South End', inHours: 26, size: 2, max: 6 }, - { actIdx: 3, location: 'High Street', inHours: 44, size: 3, max: 4 }, - { actIdx: 5, location: 'Lincoln Tower Fields', inHours: 58, size: 7, max: 10 }, - { actIdx: 2, location: 'Thompson Library – Reading Room', inHours: 74, size: 4, max: 6 }, - { actIdx: 4, location: 'The Oval – North End', inHours: 92, size: 3, max: 6 }, - { actIdx: 1, location: 'The Lounge – High Street', inHours: 110, size: 2, max: 4 }, - { actIdx: 0, location: 'The Oval', inHours: 122, size: 4, max: 8 }, - { actIdx: 3, location: 'High Street', inHours: 140, size: 2, max: 4 }, - { actIdx: 5, location: 'Lincoln Tower Fields', inHours: 152, size: 5, max: 10 }, - { actIdx: 2, location: 'Thompson Library – 11th Floor', inHours: 164, size: 3, max: 5 }, + { actIdx: 1, location: 'The Lounge – High Street', day: 1, at: [19, 0], size: 4, max: 4, chat: true }, + { actIdx: 2, location: 'Thompson Library – 11th Floor', day: 1, at: [10, 0], size: 3, max: 5 }, + { actIdx: 4, location: 'The Oval', day: 1, at: [16, 30], size: 2, max: 4 }, + { actIdx: 0, location: 'The Oval – South End', day: 2, at: [12, 0], size: 2, max: 6 }, + { actIdx: 3, location: 'High Street', day: 2, at: [20, 0], size: 3, max: 4 }, + { actIdx: 5, location: 'Lincoln Tower Fields', day: 3, at: [17, 30], size: 7, max: 10 }, + { actIdx: 2, location: 'Thompson Library – Reading Room', day: 3, at: [14, 0], size: 4, max: 6 }, + { actIdx: 4, location: 'The Oval – North End', day: 4, at: [9, 30], size: 3, max: 6 }, + { actIdx: 1, location: 'The Lounge – High Street', day: 4, at: [19, 30], size: 2, max: 4 }, + { actIdx: 0, location: 'The Oval', day: 5, at: [11, 0], size: 4, max: 8 }, + { actIdx: 3, location: 'High Street', day: 5, at: [18, 0], size: 2, max: 4 }, + { actIdx: 5, location: 'Lincoln Tower Fields', day: 6, at: [15, 0], size: 5, max: 10 }, + { actIdx: 2, location: 'Thompson Library – 11th Floor', day: 7, at: [13, 30], size: 3, max: 5 }, ]; let chatPodId = ''; for (let i = 0; i < podSpecs.length; i++) { @@ -221,7 +267,7 @@ async function main() { const pod = await prisma.pod.create({ data: { activityId: activityIds[s.actIdx], - meetupTime: hrs(s.inHours), + meetupTime: upcomingAtLocal(s.day, s.at[0], s.at[1]), location: s.location, minMembers: 2, maxMembers: s.max, @@ -305,6 +351,42 @@ async function main() { }); manifest.clubs.push(club.id); + const rsvpEveryone = async (meetingId: string) => { + const going = Math.round(memberUserIds.length * 0.6); + const maybe = Math.round(memberUserIds.length * 0.25); + const notGoing = Math.round(memberUserIds.length * 0.08); + const rows: { meetingId: string; userId: string; status: string }[] = []; + let k = 0; + for (; k < going && k < memberUserIds.length; k++) rows.push({ meetingId, userId: memberUserIds[k], status: 'GOING' }); + for (; k < going + maybe && k < memberUserIds.length; k++) rows.push({ meetingId, userId: memberUserIds[k], status: 'MAYBE' }); + for (; k < going + maybe + notGoing && k < memberUserIds.length; k++) rows.push({ meetingId, userId: memberUserIds[k], status: 'NOT_GOING' }); + await prisma.clubMeetingAttendee.createMany({ data: rows, skipDuplicates: true }); + }; + + // --- Tonight's meeting (showcase club) --- + // Deliberately above the existingClub guard so it is refreshed on every + // run: a rerun on a later day would otherwise leave yesterday's "tonight" + // meeting in place and the "Tonight on campus" rail would go empty. + // 5:00 PM today — GET /clubs/today spans the whole local day and neither + // side filters on `now`, so this still shows under "Tonight on campus" + // when the seed is run late in the evening. + if (c.showcase) { + await prisma.clubMeeting.deleteMany({ where: { clubId: club.id, title: TONIGHT_MEETING_TITLE } }); + const tonight = await prisma.clubMeeting.create({ + data: { + clubId: club.id, + title: TONIGHT_MEETING_TITLE, + description: 'Weekly build night — bring a laptop and a project, or pair with someone on theirs.', + location: 'Engineering Building – Room 266', + meetingTime: atLocal(0, TONIGHT_MEETING_HOUR, TONIGHT_MEETING_MINUTE), + createdById: ownerId, + isPublic: true, + visibility: 'PUBLIC', + }, + }); + await rsvpEveryone(tonight.id); + } + // Content below only on first creation — reruns on an existing club would // duplicate meetings/announcements/chat. if (existingClub) { @@ -314,36 +396,28 @@ async function main() { // Meetings (+ RSVP attendees so "X going · Y maybe" looks real) const meetingDefs = [ - { title: `${c.name.split(/[ &]/)[0]} Weekly Meeting`, description: 'Our regular weekly get-together. New members always welcome.', location: 'Student Union – Meeting Room A', inHours: 48 }, + { title: `${c.name.split(/[ &]/)[0]} Weekly Meeting`, description: 'Our regular weekly get-together. New members always welcome.', location: 'Student Union – Meeting Room A', day: 2, at: [18, 0] }, ]; if (c.showcase) { - meetingDefs.push({ title: 'Hackathon Prep Night', description: 'Form teams and lock in project ideas before the hackathon.', location: 'Engineering Building – Room 266', inHours: 72 }); + meetingDefs.push({ title: 'Hackathon Prep Night', description: 'Form teams and lock in project ideas before the hackathon.', location: 'Engineering Building – Room 266', day: 3, at: [19, 0] }); } for (const md of meetingDefs) { const meeting = await prisma.clubMeeting.create({ - data: { clubId: club.id, title: md.title, description: md.description, location: md.location, meetingTime: hrs(md.inHours), createdById: ownerId, isPublic: true }, + data: { clubId: club.id, title: md.title, description: md.description, location: md.location, meetingTime: upcomingAtLocal(md.day, md.at[0], md.at[1]), createdById: ownerId, isPublic: true, visibility: 'PUBLIC' }, }); - const going = Math.round(memberUserIds.length * 0.6); - const maybe = Math.round(memberUserIds.length * 0.25); - const notGoing = Math.round(memberUserIds.length * 0.08); - const rows: { meetingId: string; userId: string; status: string }[] = []; - let k = 0; - for (; k < going && k < memberUserIds.length; k++) rows.push({ meetingId: meeting.id, userId: memberUserIds[k], status: 'GOING' }); - for (; k < going + maybe && k < memberUserIds.length; k++) rows.push({ meetingId: meeting.id, userId: memberUserIds[k], status: 'MAYBE' }); - for (; k < going + maybe + notGoing && k < memberUserIds.length; k++) rows.push({ meetingId: meeting.id, userId: memberUserIds[k], status: 'NOT_GOING' }); - await prisma.clubMeetingAttendee.createMany({ data: rows, skipDuplicates: true }); + await rsvpEveryone(meeting.id); } // Announcements await prisma.clubAnnouncement.create({ data: { clubId: club.id, userId: ownerId, content: 'Welcome to everyone who signed up this week! Check the meetings tab for our next event. 🎉' } }); if (c.showcase) { - await prisma.clubAnnouncement.create({ data: { clubId: club.id, userId: ownerId, content: 'Build night Thursday at 6pm in Room 266. Pizza provided 🍕 bring a laptop!' } }); + await prisma.clubAnnouncement.create({ data: { clubId: club.id, userId: ownerId, content: 'Build night tonight at 5pm in Room 266. Pizza provided 🍕 bring a laptop!' } }); showcaseClubId = club.id; } // General-channel chat (channelId null) — in EVERY club so each has a live chat const lines = c.showcase - ? ['welcome to everyone who joined at the involvement fair! 👋', 'first build night is this thursday, bring a laptop', 'anyone want to pair on the hackathon project?', "i'm in — i've got some ideas for the frontend", 'we have pizza covered for thursday btw', 'what room are we in again?', 'Room 266, 6pm. see you all there!'] + ? ['welcome to everyone who joined at the involvement fair! 👋', 'build night is tonight, bring a laptop', 'anyone want to pair on the hackathon project?', "i'm in — i've got some ideas for the frontend", 'we have pizza covered for tonight btw', 'what room are we in again?', 'Room 266, 5pm. see you all there!'] : Array.from({ length: 7 }, (_, m) => CLUB_CHAT_POOL[(ci * 2 + m) % CLUB_CHAT_POOL.length]); for (let m = 0; m < lines.length; m++) { await prisma.clubMessage.create({ diff --git a/backend/src/config/productionEnv.test.ts b/backend/src/config/productionEnv.test.ts index b599746..a4fa546 100644 --- a/backend/src/config/productionEnv.test.ts +++ b/backend/src/config/productionEnv.test.ts @@ -31,9 +31,18 @@ describe('production environment validation', () => { DATABASE_URL: validEnv.DATABASE_URL, JWT_SECRET: validEnv.JWT_SECRET, CORS_ORIGIN: validEnv.CORS_ORIGIN, + SUPABASE_URL: validEnv.SUPABASE_URL, + SUPABASE_SERVICE_ROLE_KEY: validEnv.SUPABASE_SERVICE_ROLE_KEY, })).not.toThrow(); }); + it('rejects missing realtime credentials', () => { + expect(() => validateProductionEnvironment({ + ...validEnv, + SUPABASE_SERVICE_ROLE_KEY: undefined, + })).toThrow(/SUPABASE_SERVICE_ROLE_KEY is missing/); + }); + it('rejects missing core values and invalid configured secrets', () => { expect(() => validateProductionEnvironment({ ...validEnv, diff --git a/backend/src/config/productionEnv.ts b/backend/src/config/productionEnv.ts index 40d3322..799c33c 100644 --- a/backend/src/config/productionEnv.ts +++ b/backend/src/config/productionEnv.ts @@ -2,6 +2,8 @@ const REQUIRED_PRODUCTION_ENV = [ 'DATABASE_URL', 'JWT_SECRET', 'CORS_ORIGIN', + 'SUPABASE_URL', + 'SUPABASE_SERVICE_ROLE_KEY', ] as const; const SECRET_ENV = new Set([ @@ -49,14 +51,8 @@ export function validateProductionEnvironment( throw new Error(`Invalid production environment:\n- ${errors.join('\n- ')}`); } - // Soft warnings: these degrade features silently rather than breaking the + // Soft warnings: these degrade optional features rather than breaking the // app, which makes the failure invisible in testing — say so in the logs. - if (!env.SUPABASE_URL?.trim() || !env.SUPABASE_SERVICE_ROLE_KEY?.trim()) { - console.warn( - '[env] SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY not set — realtime broadcasts are ' + - 'DISABLED; clients will fall back to polling for chat, typing, and inbox badges.', - ); - } if (!androidFingerprint) { console.warn( '[env] ANDROID_SHA256_CERT_FINGERPRINT is not set — Android App Links cannot verify ' + diff --git a/backend/src/lib/NotificationService.test.ts b/backend/src/lib/NotificationService.test.ts index 2c064a5..a196a3e 100644 --- a/backend/src/lib/NotificationService.test.ts +++ b/backend/src/lib/NotificationService.test.ts @@ -12,6 +12,14 @@ const { mockReceiptChunk, mockGetReceipts, mockUserUpdateMany, + mockUserFindMany, + mockPodMemberFindMany, + mockAnalyticsCount, + mockAnalyticsCreate, + mockPodInviteFindUnique, + mockFriendRequestFindUnique, + mockClubFindUnique, + mockClubChannelFindFirst, mockQueryRaw, mockExecuteRaw, } = vi.hoisted(() => ({ @@ -23,6 +31,14 @@ const { mockReceiptChunk: vi.fn((ids: unknown[]) => [ids]), mockGetReceipts: vi.fn().mockResolvedValue({}), mockUserUpdateMany: vi.fn().mockResolvedValue({ count: 1 }), + mockUserFindMany: vi.fn().mockResolvedValue([]), + mockPodMemberFindMany: vi.fn().mockResolvedValue([]), + mockAnalyticsCount: vi.fn().mockResolvedValue(0), + mockAnalyticsCreate: vi.fn().mockResolvedValue({}), + mockPodInviteFindUnique: vi.fn(), + mockFriendRequestFindUnique: vi.fn(), + mockClubFindUnique: vi.fn(), + mockClubChannelFindFirst: vi.fn(), mockQueryRaw: vi.fn().mockResolvedValue([]), mockExecuteRaw: vi.fn().mockResolvedValue(0), })); @@ -48,7 +64,26 @@ vi.mock('../prisma', () => ({ }, user: { updateMany: (...args: unknown[]) => mockUserUpdateMany(...args), - findMany: () => Promise.resolve([]), + findMany: (...args: unknown[]) => mockUserFindMany(...args), + }, + podMember: { + findMany: (...args: unknown[]) => mockPodMemberFindMany(...args), + }, + analyticsEvent: { + count: (...args: unknown[]) => mockAnalyticsCount(...args), + create: (...args: unknown[]) => mockAnalyticsCreate(...args), + }, + podInvite: { + findUnique: (...args: unknown[]) => mockPodInviteFindUnique(...args), + }, + friendRequest: { + findUnique: (...args: unknown[]) => mockFriendRequestFindUnique(...args), + }, + club: { + findUnique: (...args: unknown[]) => mockClubFindUnique(...args), + }, + clubChannel: { + findFirst: (...args: unknown[]) => mockClubChannelFindFirst(...args), }, $queryRaw: (...args: unknown[]) => mockQueryRaw(...args), $executeRaw: (...args: unknown[]) => mockExecuteRaw(...args), @@ -92,6 +127,10 @@ describe('parsePreferences', () => { describe('NotificationService.notifyPodJoin', () => { beforeEach(() => { vi.clearAllMocks(); + mockUserFindMany.mockResolvedValue([]); + mockPodMemberFindMany.mockResolvedValue([]); + mockAnalyticsCount.mockResolvedValue(0); + mockAnalyticsCreate.mockResolvedValue({}); mockSend.mockResolvedValue([]); mockChunk.mockImplementation((msgs: unknown[]) => [msgs]); mockPodUpdateMany.mockResolvedValue({ count: 1 }); @@ -184,6 +223,67 @@ describe('NotificationService.notifyPodJoin', () => { }); }); +describe('NotificationService invitation and connection pushes', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockChunk.mockImplementation((msgs: unknown[]) => [msgs]); + mockSend.mockResolvedValue([{ status: 'ok', id: 'ticket-1' }]); + }); + + it('sends a pod invitation with useful plan details', async () => { + const token = 'ExponentPushToken[inviteinviteinviteinvite]'; + mockPodInviteFindUnique.mockResolvedValue({ + id: 'invite-1', + podId: 'pod-1', + sender: { name: 'Maya Chen', firstName: 'Maya', lastName: 'Chen' }, + receiver: { pushToken: token, notificationPreferences: null }, + pod: { + id: 'pod-1', + title: null, + meetupTime: new Date(2026, 7, 14, 19, 0), + location: 'RPAC Courts', + maxMembers: 6, + activity: { title: 'Pickup Basketball' }, + members: [{ userId: 'sender' }, { userId: 'other' }], + }, + }); + + const { NotificationService } = await import('./NotificationService'); + await NotificationService.notifyPodInvite('invite-1'); + + expect(mockChunk).toHaveBeenCalledWith([ + expect.objectContaining({ + to: token, + title: 'Maya invited you to Pickup Basketball', + body: expect.stringContaining('RPAC Courts'), + channelId: 'plans', + data: expect.objectContaining({ type: 'pod_invite', url: 'oval://pod/pod-1' }), + }), + ]); + }); + + it('sends an incoming friend request to the Inbox', async () => { + const token = 'ExponentPushToken[friendfriendfriendfriend]'; + mockFriendRequestFindUnique.mockResolvedValue({ + id: 'request-1', + sender: { id: 'sender-1', name: 'Maya Chen', firstName: 'Maya', lastName: 'Chen' }, + receiver: { pushToken: token, notificationPreferences: null }, + }); + + const { NotificationService } = await import('./NotificationService'); + await NotificationService.notifyFriendRequest('request-1'); + + expect(mockChunk).toHaveBeenCalledWith([ + expect.objectContaining({ + to: token, + title: 'Maya sent you a friend request', + channelId: 'messages', + data: expect.objectContaining({ type: 'friend_request', url: 'oval://inbox' }), + }), + ]); + }); +}); + describe('NotificationService.notifyNewMessage', () => { beforeEach(() => { vi.clearAllMocks(); @@ -270,6 +370,60 @@ describe('NotificationService.notifyNewMessage', () => { }); }); +describe('NotificationService.notifyClubMessage', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockChunk.mockImplementation((msgs: unknown[]) => [msgs]); + mockSend.mockResolvedValue([{ status: 'ok', id: 'club-ticket' }]); + }); + + it('sends a channel-specific preview to eligible club members', async () => { + const token = 'ExponentPushToken[clubclubclubclubclub]'; + mockClubFindUnique.mockResolvedValue({ + id: 'club-1', + name: 'Coding Club', + roles: [], + members: [ + { + role: 'OWNER', + customRoles: [], + user: { id: 'sender', name: 'Maya', firstName: 'Maya', lastName: '', pushToken: null, notificationPreferences: null }, + }, + { + role: 'MEMBER', + customRoles: [], + user: { id: 'recipient', name: 'Jordan', firstName: 'Jordan', lastName: '', pushToken: token, notificationPreferences: null }, + }, + ], + }); + mockClubChannelFindFirst.mockResolvedValue({ + id: 'general-1', + kind: 'GENERAL', + name: 'General', + allowedRoleIds: null, + allowedUserIds: null, + }); + + const { NotificationService } = await import('./NotificationService'); + await NotificationService.notifyClubMessage('club-1', 'sender', 'Room changed to Enarson 204', { + channelKind: 'GENERAL', + }); + + expect(mockChunk).toHaveBeenCalledWith([ + expect.objectContaining({ + to: token, + title: 'Maya · Coding Club #General', + body: 'Room changed to Enarson 204', + channelId: 'messages', + data: expect.objectContaining({ + type: 'club_message', + url: 'oval://clubs/club-1/chat/general-1', + }), + }), + ]); + }); +}); + describe('NotificationService.sendMeetupReminders', () => { beforeEach(() => { vi.clearAllMocks(); @@ -322,7 +476,7 @@ describe('NotificationService.sendMeetupReminders', () => { expect.arrayContaining([ expect.objectContaining({ to: token, - title: 'Meetup in 1 hour!', + title: 'Morning Run starts in 1 hour', data: expect.objectContaining({ url: 'oval://pod/pod1' }), }), ]) @@ -358,6 +512,12 @@ describe('NotificationService.sendMeetupReminders', () => { describe('NotificationService.sendWeeklyRecaps gate', () => { beforeEach(() => { vi.clearAllMocks(); + mockSend.mockResolvedValue([]); + mockPodFindMany.mockResolvedValue([]); + mockUserFindMany.mockResolvedValue([]); + mockPodMemberFindMany.mockResolvedValue([]); + mockAnalyticsCount.mockResolvedValue(0); + mockAnalyticsCreate.mockResolvedValue({}); }); it('does nothing on non-Sunday days', async () => { @@ -383,6 +543,41 @@ describe('NotificationService.sendWeeklyRecaps gate', () => { await NotificationService.sendWeeklyRecaps(new Date(2026, 6, 5, 21, 40)); expect(mockPodFindMany).toHaveBeenCalled(); }); + + it('turns a zero-activity week into a useful planning notification without zero stats', async () => { + const now = new Date(2026, 6, 5, 18, 15); + const token = 'ExponentPushToken[weeklyweeklyweeklyweekly]'; + mockPodFindMany.mockResolvedValue([ + { id: 'p1', activity: { category: 'Sports' }, members: [] }, + { id: 'p2', activity: { category: 'Social' }, members: [] }, + { id: 'p3', activity: { category: 'Food' }, members: [] }, + ]); + mockUserFindMany.mockResolvedValue([ + { id: 'u1', pushToken: token, notificationPreferences: null, interestTags: null }, + ]); + mockPodMemberFindMany.mockResolvedValue([]); + mockSend.mockResolvedValue([{ status: 'ok', id: 'weekly-ticket' }]); + + const { NotificationService } = await import('./NotificationService'); + const result = await NotificationService.sendWeeklyRecaps(now); + + expect(result).toEqual({ attempted: 1, sent: 1 }); + expect(mockPodFindMany).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ + meetupTime: { + gt: now, + lte: new Date(2026, 6, 12, 18, 15), + }, + }), + })); + expect(mockChunk).toHaveBeenCalledWith([ + expect.objectContaining({ + title: '3 plans match your interests', + body: "See what's forming around campus this week.", + channelId: 'discovery', + }), + ]); + }); }); describe('NotificationService.checkPushReceipts', () => { diff --git a/backend/src/lib/NotificationService.ts b/backend/src/lib/NotificationService.ts index fc3890f..34606f9 100644 --- a/backend/src/lib/NotificationService.ts +++ b/backend/src/lib/NotificationService.ts @@ -3,54 +3,41 @@ import { Prisma } from '@prisma/client'; import prisma from '../prisma'; import { getPublicName } from './userNames'; import { getInterestCategories } from '../config/interestTags'; +import { + PUSH_CHANNELS, + clubAnnouncementCopy, + clubAttendanceOpenCopy, + clubMessageCopy, + clubMeetingCreatedCopy, + clubOutreachCopy, + clubRemovalCopy, + clubRoleChangeCopy, + clubRsvpCopy, + demandConversionCopy, + demandPlanCreatedCopy, + directMessageCopy, + firstPlanNudgeCopy, + friendRequestCopy, + meetupReminderCopy, + podCancelledCopy, + podInviteCopy, + podJoinCopy, + podMessageCopy, + podUpdateCopy, + recapPromptCopy, + waitlistSpotCopy, + waitlistTwinCopy, + weeklyPlanningCopy, +} from './notificationCopy'; +import { + DEFAULT_NOTIFICATION_PREFERENCES, + parseNotificationPreferences, +} from './notificationPreferences'; const expo = new Expo(); -interface NotificationPreferences { - podJoin: boolean; - newMessage: boolean; - meetupReminder: boolean; - recapPrompt: boolean; - waitlistSpot: boolean; - clubMeetingCreated: boolean; - clubAnnouncementCreated: boolean; - clubKick: boolean; - clubRoleChange: boolean; - clubAttendanceOpen: boolean; - clubRsvpReminder: boolean; - clubOutreach: boolean; - clubRolePing: boolean; - weeklyRecap: boolean; - /** Demand-pool pushes ("6 people want a boba run" / "a pod just went up"). */ - demandAlerts: boolean; -} - -export const DEFAULT_PREFS: NotificationPreferences = { - podJoin: true, - newMessage: true, - meetupReminder: true, - recapPrompt: true, - waitlistSpot: true, - clubMeetingCreated: true, - clubAnnouncementCreated: true, - clubKick: true, - clubRoleChange: true, - clubAttendanceOpen: true, - clubRsvpReminder: true, - clubOutreach: true, - clubRolePing: true, - weeklyRecap: true, - demandAlerts: true, -}; - -export function parsePreferences(raw: string | null | undefined): NotificationPreferences { - if (!raw) return { ...DEFAULT_PREFS }; - try { - return { ...DEFAULT_PREFS, ...JSON.parse(raw) }; - } catch { - return { ...DEFAULT_PREFS }; - } -} +export const DEFAULT_PREFS = DEFAULT_NOTIFICATION_PREFERENCES; +export const parsePreferences = parseNotificationPreferences; const MEETUP_REMINDER_EARLY_MS = 30 * 60 * 1000; const MEETUP_REMINDER_LATE_MS = 70 * 60 * 1000; @@ -74,6 +61,10 @@ function threadUrl(threadId: string): string { return `oval://thread/${threadId}`; } +function inboxUrl(): string { + return 'oval://inbox'; +} + function clubUrl(clubId: string): string { return `oval://clubs/${clubId}`; } @@ -82,20 +73,21 @@ function clubMeetingUrl(clubId: string, meetingId: string): string { return `oval://clubs/${clubId}/events/${meetingId}`; } -function discoverUrl(): string { - return 'oval://explore'; +function clubChatUrl(clubId: string, channelId: string): string { + return `oval://clubs/${clubId}/chat/${channelId}`; } -function plansUrl(): string { - return 'oval://pods'; +function discoverUrl(): string { + return 'oval://explore'; } function tokenList(to: ExpoPushMessage['to']): string[] { return Array.isArray(to) ? to : [to]; } -async function send(messages: ExpoPushMessage[]): Promise { - if (messages.length === 0) return; +async function send(messages: ExpoPushMessage[]): Promise { + if (messages.length === 0) return 0; + let accepted = 0; const chunks = expo.chunkPushNotifications(messages); for (const chunk of chunks) { try { @@ -105,11 +97,25 @@ async function send(messages: ExpoPushMessage[]): Promise { // instances — an in-memory queue is always empty by the time the // cron instance runs. const rows: Array<{ id: string; token: string }> = []; + const deadTokens: string[] = []; tickets.forEach((ticket, index) => { - if (ticket.status !== 'ok') return; const [token] = tokenList(chunk[index].to); + if (ticket.status === 'error') { + console.error('[NotificationService] Push ticket rejected:', ticket.message, ticket.details); + if (ticket.details?.error === 'DeviceNotRegistered' && token) { + deadTokens.push(token); + } + return; + } + accepted += 1; if (token) rows.push({ id: ticket.id, token }); }); + if (deadTokens.length > 0) { + await prisma.user.updateMany({ + where: { pushToken: { in: deadTokens } }, + data: { pushToken: null }, + }); + } if (rows.length > 0) { try { await prisma.$executeRaw` @@ -125,11 +131,11 @@ async function send(messages: ExpoPushMessage[]): Promise { console.error('[NotificationService] Push send failed:', err); } } + return accepted; } async function sendAndCount(messages: ExpoPushMessage[]): Promise { - await send(messages); - return messages.length; + return send(messages); } function parseStringList(raw: string | null | undefined): string[] { @@ -148,12 +154,6 @@ function startOfDay(date: Date): Date { return copy; } -function startOfWeek(date: Date): Date { - const copy = startOfDay(date); - copy.setDate(copy.getDate() - copy.getDay()); - return copy; -} - function sameLocalDate(a: Date, b: Date): boolean { return ( a.getFullYear() === b.getFullYear() && @@ -162,6 +162,20 @@ function sameLocalDate(a: Date, b: Date): boolean { ); } +function formatPlanWhen(date: Date | null | undefined): string { + if (!date || Number.isNaN(date.getTime())) return ''; + return date.toLocaleDateString('en-US', { + weekday: 'long', + hour: 'numeric', + minute: '2-digit', + }); +} + +function formatTime(date: Date | null | undefined): string { + if (!date || Number.isNaN(date.getTime())) return ''; + return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); +} + /** "tonight" / "today" / "tomorrow" / "on Saturday" for push copy. */ function meetupDayLabel(meetupTime: Date, now = new Date()): string { const dayDiff = Math.round( @@ -191,7 +205,8 @@ async function sendNonTransactional( ): Promise { if (!user.pushToken || !Expo.isExpoPushToken(user.pushToken)) return false; if (!(await canSpendNonTransactionalBudget(user.id, now))) return false; - await send([{ ...message, to: user.pushToken }]); + const accepted = await send([{ ...message, to: user.pushToken }]); + if (accepted === 0) return false; await prisma.analyticsEvent.create({ data: { userId: user.id, @@ -297,14 +312,22 @@ export const NotificationService = { const joiner = pod.members.find((m) => m.user.id === joinerId); const joinerName = joiner?.user ? getPublicName(joiner.user) : 'Someone'; + const planTitle = podDisplayTitle(pod, 'your plan'); + const copy = podJoinCopy( + joinerName, + planTitle, + pod.members.length, + pod.maxMembers, + formatPlanWhen(pod.meetupTime), + ); await send([ { to: pod.creator.pushToken, - title: podDisplayTitle(pod, 'Your Pod'), - body: `${joinerName} joined your pod!`, + ...copy, data: { type: 'pod_join', podId, url: podUrl(podId) }, sound: 'default', + channelId: PUSH_CHANNELS.plans, }, ]); } catch (err) { @@ -312,6 +335,72 @@ export const NotificationService = { } }, + /** Notify a user that a friend invited them to a forming plan. */ + async notifyPodInvite(inviteId: string): Promise { + try { + const invite = await prisma.podInvite.findUnique({ + where: { id: inviteId }, + include: { + sender: { select: { name: true, firstName: true, lastName: true } }, + receiver: { select: { pushToken: true, notificationPreferences: true } }, + pod: { + include: { + activity: { select: { title: true } }, + members: { select: { userId: true } }, + }, + }, + }, + }); + if (!invite?.receiver.pushToken || !Expo.isExpoPushToken(invite.receiver.pushToken)) return; + const prefs = parsePreferences(invite.receiver.notificationPreferences); + if (!prefs.podInvite) return; + + const copy = podInviteCopy( + getPublicName(invite.sender), + podDisplayTitle(invite.pod, 'a plan'), + formatPlanWhen(invite.pod.meetupTime), + invite.pod.location, + Math.max(0, invite.pod.maxMembers - invite.pod.members.length), + ); + await send([{ + to: invite.receiver.pushToken, + ...copy, + data: { type: 'pod_invite', inviteId, podId: invite.podId, url: podUrl(invite.podId) }, + sound: 'default', + channelId: PUSH_CHANNELS.plans, + }]); + } catch (err) { + console.error('[NotificationService] notifyPodInvite error:', err); + } + }, + + /** Notify a user about an incoming friend request. */ + async notifyFriendRequest(requestId: string): Promise { + try { + const request = await prisma.friendRequest.findUnique({ + where: { id: requestId }, + include: { + sender: { select: { id: true, name: true, firstName: true, lastName: true } }, + receiver: { select: { pushToken: true, notificationPreferences: true } }, + }, + }); + if (!request?.receiver.pushToken || !Expo.isExpoPushToken(request.receiver.pushToken)) return; + const prefs = parsePreferences(request.receiver.notificationPreferences); + if (!prefs.friendRequest) return; + const senderName = getPublicName(request.sender); + const copy = friendRequestCopy(senderName); + await send([{ + to: request.receiver.pushToken, + ...copy, + data: { type: 'friend_request', requestId, userId: request.sender.id, url: inboxUrl() }, + sound: 'default', + channelId: PUSH_CHANNELS.messages, + }]); + } catch (err) { + console.error('[NotificationService] notifyFriendRequest error:', err); + } + }, + /** * Notify all pod members except the actor that the pod's plan changed * (details edited) or the pod was cancelled. Uses the meetupReminder @@ -321,7 +410,8 @@ export const NotificationService = { podId: string, actorId: string, kind: 'updated' | 'cancelled', - memberUserIds?: string[] + memberUserIds?: string[], + changedFields: string[] = [], ): Promise { try { const pod = await prisma.pod.findUnique({ @@ -348,11 +438,15 @@ export const NotificationService = { }) : pod.members.map((m) => m.user); - const title = podDisplayTitle(pod, 'Your pod'); - const body = - kind === 'cancelled' - ? 'This pod was cancelled by its creator.' - : 'The pod details changed — check the updated plan.'; + const planTitle = podDisplayTitle(pod, 'Your plan'); + const copy = kind === 'cancelled' + ? podCancelledCopy(planTitle, formatPlanWhen(pod.meetupTime)) + : podUpdateCopy( + planTitle, + changedFields, + formatPlanWhen(pod.meetupTime), + pod.location, + ); const messages: ExpoPushMessage[] = []; for (const user of recipients) { @@ -362,10 +456,10 @@ export const NotificationService = { if (!prefs.meetupReminder) continue; messages.push({ to: user.pushToken, - title, - body, + ...copy, data: { type: kind === 'cancelled' ? 'pod_cancelled' : 'pod_updated', podId, url: podUrl(podId) }, sound: 'default', + channelId: PUSH_CHANNELS.plans, }); } await send(messages); @@ -375,7 +469,7 @@ export const NotificationService = { }, /** Notify all other pod members when someone sends a message. */ - async notifyNewMessage(podId: string, senderId: string): Promise { + async notifyNewMessage(podId: string, senderId: string, preview?: string): Promise { try { const pod = await prisma.pod.findUnique({ where: { id: podId }, @@ -395,6 +489,7 @@ export const NotificationService = { const sender = pod.members.find((m) => m.user.id === senderId); const senderName = sender?.user ? getPublicName(sender.user) : 'Someone'; + const copy = podMessageCopy(senderName, podDisplayTitle(pod, 'Your plan'), preview); const messages: ExpoPushMessage[] = []; for (const member of pod.members) { @@ -404,10 +499,10 @@ export const NotificationService = { if (!prefs.newMessage) continue; messages.push({ to: member.user.pushToken, - title: podDisplayTitle(pod, 'Pod Message'), - body: `${senderName} sent a message`, + ...copy, data: { type: 'new_message', podId, url: podUrl(podId) }, sound: 'default', + channelId: PUSH_CHANNELS.messages, }); } @@ -418,7 +513,7 @@ export const NotificationService = { }, /** Notify the other participant when someone sends a DM. */ - async notifyDirectMessage(threadId: string, senderId: string): Promise { + async notifyDirectMessage(threadId: string, senderId: string, preview?: string): Promise { try { const thread = await prisma.directMessageThread.findUnique({ where: { id: threadId }, @@ -438,14 +533,15 @@ export const NotificationService = { if (!recipient.pushToken || !Expo.isExpoPushToken(recipient.pushToken)) return; const prefs = parsePreferences(recipient.notificationPreferences); if (!prefs.newMessage) return; + const copy = directMessageCopy(getPublicName(sender), preview); await send([ { to: recipient.pushToken, - title: getPublicName(sender), - body: 'Sent you a message', + ...copy, data: { type: 'direct_message', threadId, url: threadUrl(threadId) }, sound: 'default', + channelId: PUSH_CHANNELS.messages, }, ]); } catch (err) { @@ -490,12 +586,18 @@ export const NotificationService = { if (!member.user.pushToken || !Expo.isExpoPushToken(member.user.pushToken)) continue; const prefs = parsePreferences(member.user.notificationPreferences); if (!prefs.meetupReminder) continue; + const copy = meetupReminderCopy( + podDisplayTitle(pod, 'Your plan'), + formatTime(pod.meetupTime), + pod.location, + pod.members.length, + ); messages.push({ to: member.user.pushToken, - title: 'Meetup in 1 hour!', - body: `${podDisplayTitle(pod, 'Your meetup')} at ${pod.location}`, + ...copy, data: { type: 'meetup_reminder', podId: pod.id, url: podUrl(pod.id) }, sound: 'default', + channelId: PUSH_CHANNELS.plans, }); } } @@ -540,15 +642,17 @@ export const NotificationService = { const messages: ExpoPushMessage[] = []; for (const pod of pods) { for (const member of pod.members) { + if (!member.confirmedAt) continue; if (!member.user.pushToken || !Expo.isExpoPushToken(member.user.pushToken)) continue; const prefs = parsePreferences(member.user.notificationPreferences); if (!prefs.recapPrompt) continue; + const copy = recapPromptCopy(podDisplayTitle(pod, 'your plan')); messages.push({ to: member.user.pushToken, - title: 'How was it?', - body: `Rate your ${podDisplayTitle(pod, 'meetup')} experience`, + ...copy, data: { type: 'recap_prompt', podId: pod.id, url: podUrl(pod.id) }, sound: 'default', + channelId: PUSH_CHANNELS.plans, }); } @@ -592,6 +696,11 @@ export const NotificationService = { if (!newPod || entries.length === 0) return; const messages: ExpoPushMessage[] = []; + const copy = waitlistTwinCopy( + podDisplayTitle(newPod, 'plan'), + formatPlanWhen(newPod.meetupTime), + newPod.location, + ); for (const entry of entries) { if (entry.userId === creatorId) continue; if (!entry.user.pushToken || !Expo.isExpoPushToken(entry.user.pushToken)) continue; @@ -599,10 +708,10 @@ export const NotificationService = { if (!prefs.waitlistSpot) continue; messages.push({ to: entry.user.pushToken, - title: 'A second pod opened', - body: `${podDisplayTitle(newPod, 'That plan')} was full, so a twin just went up — grab a spot`, + ...copy, data: { type: 'waitlist_twin', podId: newPodId, url: podUrl(newPodId) }, sound: 'default', + channelId: PUSH_CHANNELS.plans, }); } await send(messages); @@ -631,14 +740,15 @@ export const NotificationService = { if (!entry.user.pushToken || !Expo.isExpoPushToken(entry.user.pushToken)) return; const prefs = parsePreferences(entry.user.notificationPreferences); if (!prefs.waitlistSpot) return; + const copy = waitlistSpotCopy(activityTitle ?? 'your plan'); await send([ { to: entry.user.pushToken, - title: 'A spot opened up!', - body: `A spot opened up in ${activityTitle ?? 'a pod'} — join now before it fills up`, + ...copy, data: { type: 'waitlist_spot', podId, url: podUrl(podId) }, sound: 'default', + channelId: PUSH_CHANNELS.plans, }, ]); } catch (err) { @@ -734,21 +844,27 @@ export const NotificationService = { const others = Math.max(0, membership.pod.members.length - 1); const peopleLine = anchorName && others > 1 - ? `${anchorName} and ${others} others will be there` + ? `${anchorName} + ${others - 1} ${others - 1 === 1 ? 'other' : 'others'} are going` : anchorName - ? `${anchorName} will be there` - : `${membership.pod.members.length} people are in`; + ? `${anchorName} is going` + : "You're the first one in"; const meetupTime = membership.pod.meetupTime.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', }); + const copy = firstPlanNudgeCopy( + podDisplayTitle(membership.pod, 'Your plan'), + meetupTime, + membership.pod.location, + peopleLine, + ); messages.push({ to: membership.user.pushToken, - title: `Tonight: ${podDisplayTitle(membership.pod, 'your pod')} at ${meetupTime}`, - body: peopleLine, + ...copy, data: { type: 'first_pod_nudge', podId: membership.podId, url: podUrl(membership.podId) }, sound: 'default', + channelId: PUSH_CHANNELS.plans, }); } @@ -817,16 +933,16 @@ export const NotificationService = { pools += 1; attempted += demands.length; + const copy = demandConversionCopy(demands.length, activity.title); for (const demand of demands) { const prefs = parsePreferences(demand.user.notificationPreferences); if (!prefs.demandAlerts) continue; const didSend = await sendNonTransactional( demand.user, { - title: `${demands.length} people are down`, - body: `${demands.length} people want ${activity.title} this week. Start it?`, + ...copy, data: { type: 'demand_conversion', activityId: activity.id, url: discoverUrl() }, - sound: 'default', + channelId: PUSH_CHANNELS.discovery, }, 'demand_conversion', now, @@ -863,7 +979,7 @@ export const NotificationService = { const [pod, demands] = await Promise.all([ prisma.pod.findUnique({ where: { id: podId }, - select: { title: true, activity: { select: { title: true } } }, + select: { title: true, location: true, activity: { select: { title: true } } }, }), prisma.podDemand.findMany({ where: { activityId, consumedAt: null, expiresAt: { gt: now } }, @@ -874,6 +990,12 @@ export const NotificationService = { if (demands.length === 0) return { attempted: 0, sent: 0 }; const dayLabel = meetupTime ? meetupDayLabel(meetupTime, now) : 'this week'; + const copy = demandPlanCreatedCopy( + pod ? podDisplayTitle(pod, 'A plan') : 'A plan', + dayLabel, + meetupTime ? formatTime(meetupTime) : undefined, + pod?.location, + ); let attempted = 0; let sent = 0; for (const demand of demands) { @@ -882,12 +1004,11 @@ export const NotificationService = { const prefs = parsePreferences(demand.user.notificationPreferences); if (!prefs.demandAlerts) continue; const didSend = await sendNonTransactional( - demand.user, - { - title: `${pod ? podDisplayTitle(pod, 'A pod') : 'A pod'} just went up`, - body: `A plan just opened ${dayLabel}. Grab a spot while it is forming.`, + demand.user, + { + ...copy, data: { type: 'demand_pod_created', activityId, podId, url: podUrl(podId) }, - sound: 'default', + channelId: PUSH_CHANNELS.discovery, }, 'demand_pod_created', now, @@ -923,16 +1044,20 @@ export const NotificationService = { if (now.getDay() !== 0 || now.getHours() < 18) return { attempted: 0, sent: 0 }; try { - const weekStart = startOfWeek(now); - const nextWeekEnd = new Date(weekStart); - nextWeekEnd.setDate(nextWeekEnd.getDate() + 14); + const recapStart = new Date(now); + recapStart.setDate(recapStart.getDate() - 7); + const planningEnd = new Date(now); + planningEnd.setDate(planningEnd.getDate() + 7); const joinablePods = await prisma.pod.findMany({ where: { status: 'FORMING', - meetupTime: { gt: now, lt: nextWeekEnd }, + meetupTime: { gt: now, lte: planningEnd }, locationType: { not: 'private' }, }, - include: { activity: { select: { category: true } } }, + include: { + activity: { select: { category: true } }, + members: { select: { userId: true } }, + }, }); const users = await prisma.user.findMany({ @@ -954,12 +1079,15 @@ export const NotificationService = { const attendedMemberships = await prisma.podMember.findMany({ where: { userId: user.id, - confirmedAt: { not: null, gte: weekStart, lte: now }, + confirmedAt: { not: null, gte: recapStart, lte: now }, }, include: { pod: { include: { - members: { select: { userId: true } }, + members: { + where: { confirmedAt: { not: null } }, + select: { userId: true }, + }, }, }, }, @@ -967,13 +1095,13 @@ export const NotificationService = { const interestCategories = getInterestCategories(user.interestTags); const matchingJoinableCount = joinablePods.filter((pod) => { + if (pod.members.some((member) => member.userId === user.id)) return false; if (interestCategories.size === 0) return true; return interestCategories.has(pod.activity.category); }).length; if (attendedMemberships.length === 0 && matchingJoinableCount === 0) continue; - attempted += 1; const metUserIds = new Set(); for (const membership of attendedMemberships) { for (const member of membership.pod.members) { @@ -981,13 +1109,45 @@ export const NotificationService = { } } + // "New people" means a first co-attendance, not merely everyone in + // this week's plans. Exclude anyone the user had already attended a + // plan with before the recap window. + let newPeopleCount = metUserIds.size; + if (metUserIds.size > 0) { + const earlierMemberships = await prisma.podMember.findMany({ + where: { userId: user.id, confirmedAt: { not: null, lt: recapStart } }, + select: { + pod: { + select: { + members: { + where: { confirmedAt: { not: null } }, + select: { userId: true }, + }, + }, + }, + }, + }); + const priorCoattendees = new Set(); + for (const membership of earlierMemberships) { + for (const member of membership.pod.members) priorCoattendees.add(member.userId); + } + newPeopleCount = [...metUserIds].filter((userId) => !priorCoattendees.has(userId)).length; + } + + const copy = weeklyPlanningCopy( + attendedMemberships.length, + newPeopleCount, + matchingJoinableCount, + ); + if (!copy) continue; + attempted += 1; + const didSend = await sendNonTransactional( user, { - title: 'Your week on Oval', - body: `Your week: ${attendedMemberships.length} pods, ${metUserIds.size} new people. ${matchingJoinableCount} pods are forming for this week.`, - data: { type: 'weekly_recap', url: matchingJoinableCount > 0 ? discoverUrl() : plansUrl() }, - sound: 'default', + ...copy, + data: { type: 'weekly_recap', url: discoverUrl() }, + channelId: PUSH_CHANNELS.discovery, }, 'weekly_recap', now, @@ -1031,6 +1191,12 @@ export const NotificationService = { }); const messages: ExpoPushMessage[] = []; + const copy = clubMeetingCreatedCopy( + meeting.club.name, + meeting.title, + meetingDate, + meeting.location, + ); for (const member of meeting.club.members) { if (member.user.id === creatorId) continue; if (!canReceiveClubBroadcast(member, meeting.visibility, targetRoleIds)) continue; @@ -1039,10 +1205,9 @@ export const NotificationService = { if (!prefs.clubMeetingCreated) continue; messages.push({ to: member.user.pushToken, - title: meeting.club.name, - body: `New meeting: ${meeting.title} on ${meetingDate}`, + ...copy, data: { type: 'club_meeting_created', clubId: meeting.clubId, meetingId, url: clubMeetingUrl(meeting.clubId, meetingId) }, - sound: 'default', + channelId: PUSH_CHANNELS.clubs, }); } await send(messages); @@ -1072,6 +1237,7 @@ export const NotificationService = { if (!announcement) return; const targetRoleIds = parseStringList(announcement.targetRoleIds); + const copy = clubAnnouncementCopy(announcement.club.name, announcement.content); const messages: ExpoPushMessage[] = []; for (const member of announcement.club.members) { if (member.user.id === creatorId) continue; @@ -1081,10 +1247,9 @@ export const NotificationService = { if (!prefs.clubAnnouncementCreated) continue; messages.push({ to: member.user.pushToken, - title: announcement.club.name, - body: announcement.content.length > 120 ? `${announcement.content.slice(0, 117)}...` : announcement.content, + ...copy, data: { type: 'club_announcement_created', clubId: announcement.clubId, announcementId, url: clubUrl(announcement.clubId) }, - sound: 'default', + channelId: PUSH_CHANNELS.clubs, }); } await send(messages); @@ -1093,6 +1258,102 @@ export const NotificationService = { } }, + /** Notify eligible members about a club chat message without double-sending role mentions. */ + async notifyClubMessage( + clubId: string, + senderId: string, + preview: string, + options: { + channelId?: string | null; + channelKind?: 'GENERAL' | 'OFFICERS'; + mentionedRoleIds?: string[]; + } = {}, + ): Promise { + try { + const [club, channel] = await Promise.all([ + prisma.club.findUnique({ + where: { id: clubId }, + include: { + roles: { select: { id: true, name: true } }, + members: { + include: { + user: { + select: { + id: true, + name: true, + firstName: true, + lastName: true, + pushToken: true, + notificationPreferences: true, + }, + }, + customRoles: { select: { roleId: true } }, + }, + }, + }, + }), + options.channelId + ? prisma.clubChannel.findFirst({ where: { id: options.channelId, clubId } }) + : prisma.clubChannel.findFirst({ + where: { clubId, kind: options.channelKind ?? 'GENERAL' }, + }), + ]); + if (!club || !channel) return; + + const senderMember = club.members.find((member) => member.user.id === senderId); + const senderName = senderMember ? getPublicName(senderMember.user) : 'Someone'; + const allowedRoleIds = parseStringList(channel.allowedRoleIds); + const allowedUserIds = parseStringList(channel.allowedUserIds); + const mentionedRoleIds = new Set(options.mentionedRoleIds ?? []); + const mentionedRoleNames = club.roles + .filter((role) => mentionedRoleIds.has(role.id)) + .map((role) => `@${role.name}`); + const mentionLabel = mentionedRoleNames.length === 1 ? mentionedRoleNames[0] : 'your role'; + const messages: ExpoPushMessage[] = []; + + for (const member of club.members) { + const user = member.user; + if (user.id === senderId) continue; + const memberRoleIds = member.customRoles.map((assignment) => assignment.roleId); + const canRead = + channel.kind === 'GENERAL' || + (channel.kind === 'OFFICERS' && roleRank(member.role) >= roleRank('OFFICER')) || + (channel.kind === 'CUSTOM' && + ((allowedRoleIds.length === 0 && allowedUserIds.length === 0) || + allowedUserIds.includes(user.id) || + memberRoleIds.some((roleId) => allowedRoleIds.includes(roleId)))); + if (!canRead) continue; + if (!user.pushToken || !Expo.isExpoPushToken(user.pushToken)) continue; + + const prefs = parsePreferences(user.notificationPreferences); + const isMentioned = memberRoleIds.some((roleId) => mentionedRoleIds.has(roleId)); + if (isMentioned ? !prefs.clubRolePing && !prefs.newMessage : !prefs.newMessage) continue; + const copy = clubMessageCopy( + senderName, + club.name, + channel.name, + preview, + isMentioned && prefs.clubRolePing ? mentionLabel : undefined, + ); + messages.push({ + to: user.pushToken, + ...copy, + data: { + type: isMentioned && prefs.clubRolePing ? 'club_role_ping' : 'club_message', + clubId, + channelId: channel.id, + url: clubChatUrl(clubId, channel.id), + }, + sound: 'default', + channelId: PUSH_CHANNELS.messages, + }); + } + await send(messages); + } catch (err) { + console.error('[NotificationService] notifyClubMessage error:', err); + } + }, + /** Notify members holding any of the pinged roles that they were mentioned in club chat. */ async notifyClubRolePing( clubId: string, @@ -1126,9 +1387,7 @@ export const NotificationService = { const senderName = sender ? getPublicName(sender) : 'Someone'; const roleNames = Array.from(new Set(holders.map((row) => row.role.name))); const mentionLabel = roleNames.length === 1 ? `@${roleNames[0]}` : `${roleIds.length} roles`; - const body = `${senderName} pinged ${mentionLabel} in #${channelName}: ${ - preview.length > 90 ? `${preview.slice(0, 87)}...` : preview - }`; + const copy = clubMessageCopy(senderName, club.name, channelName, preview, mentionLabel); const seen = new Set(); const messages: ExpoPushMessage[] = []; @@ -1141,10 +1400,10 @@ export const NotificationService = { if (!prefs.clubRolePing) continue; messages.push({ to: user.pushToken, - title: club.name, - body, + ...copy, data: { type: 'club_role_ping', clubId, url: clubUrl(clubId) }, sound: 'default', + channelId: PUSH_CHANNELS.messages, }); } await send(messages); @@ -1167,13 +1426,13 @@ export const NotificationService = { if (!user.pushToken || !Expo.isExpoPushToken(user.pushToken)) return; const prefs = parsePreferences(user.notificationPreferences); if (!prefs.clubKick) return; + const copy = clubRemovalCopy(club.name); await send([ { to: user.pushToken, - title: club.name, - body: `You have been removed from ${club.name}`, - data: { type: 'club_kick', clubId, url: clubUrl(clubId) }, - sound: 'default', + ...copy, + data: { type: 'club_kick', clubId, url: 'oval://clubs-home' }, + channelId: PUSH_CHANNELS.clubs, }, ]); } catch (err) { @@ -1195,14 +1454,13 @@ export const NotificationService = { if (!user.pushToken || !Expo.isExpoPushToken(user.pushToken)) return; const prefs = parsePreferences(user.notificationPreferences); if (!prefs.clubRoleChange) return; - const roleLabel = newRole === 'OFFICER' ? 'Officer' : 'Member'; + const copy = clubRoleChangeCopy(club.name, newRole); await send([ { to: user.pushToken, - title: club.name, - body: `Your role in ${club.name} has been updated to ${roleLabel}`, + ...copy, data: { type: 'club_role_change', clubId, newRole, url: clubUrl(clubId) }, - sound: 'default', + channelId: PUSH_CHANNELS.clubs, }, ]); } catch (err) { @@ -1228,16 +1486,17 @@ export const NotificationService = { if (!meeting) return; const messages: ExpoPushMessage[] = []; + const copy = clubAttendanceOpenCopy(meeting.title, meeting.club.name); for (const attendee of meeting.attendees) { if (!attendee.user.pushToken || !Expo.isExpoPushToken(attendee.user.pushToken)) continue; const prefs = parsePreferences(attendee.user.notificationPreferences); if (!prefs.clubAttendanceOpen) continue; messages.push({ to: attendee.user.pushToken, - title: meeting.club.name, - body: `${meeting.club.name} meeting started — check in now!`, + ...copy, data: { type: 'club_attendance_open', meetingId, url: clubMeetingUrl(meeting.clubId, meetingId) }, sound: 'default', + channelId: PUSH_CHANNELS.plans, }); } await send(messages); @@ -1268,6 +1527,7 @@ export const NotificationService = { hour: 'numeric', minute: '2-digit', }); + const copy = clubRsvpCopy(meeting.title, meeting.club.name, meetingDate); const messages: ExpoPushMessage[] = []; for (const user of users) { @@ -1276,10 +1536,10 @@ export const NotificationService = { if (!prefs.clubRsvpReminder) continue; messages.push({ to: user.pushToken, - title: meeting.club.name, - body: `RSVP for ${meeting.title} on ${meetingDate}`, + ...copy, data: { type: 'club_rsvp_reminder', clubId: meeting.club.id, meetingId, url: clubMeetingUrl(meeting.club.id, meetingId) }, sound: 'default', + channelId: PUSH_CHANNELS.clubs, }); } @@ -1300,6 +1560,7 @@ export const NotificationService = { try { const club = await prisma.club.findUnique({ where: { id: clubId }, select: { name: true } }); if (!club || recipientIds.length === 0) return { attempted: recipientIds.length, sent: 0 }; + const copy = clubOutreachCopy(club.name, content); const users = await prisma.user.findMany({ where: { id: { in: Array.from(new Set(recipientIds)).filter((id) => id !== senderId) } }, @@ -1313,10 +1574,9 @@ export const NotificationService = { if (!prefs.clubOutreach) continue; messages.push({ to: user.pushToken, - title: club.name, - body: content.length > 120 ? `${content.slice(0, 117)}...` : content, + ...copy, data: { type: 'club_outreach', clubId, url: clubUrl(clubId) }, - sound: 'default', + channelId: PUSH_CHANNELS.clubs, }); } diff --git a/backend/src/lib/notificationCopy.test.ts b/backend/src/lib/notificationCopy.test.ts new file mode 100644 index 0000000..2119f6a --- /dev/null +++ b/backend/src/lib/notificationCopy.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; +import { + clubAttendanceOpenCopy, + clubOutreachCopy, + clubRoleChangeCopy, + demandPlanCreatedCopy, + firstPlanNudgeCopy, + friendRequestCopy, + notificationPreview, + podUpdateCopy, + waitlistTwinCopy, + weeklyPlanningCopy, +} from './notificationCopy'; + +describe('notificationPreview', () => { + it('collapses whitespace and caps lock-screen copy', () => { + const preview = notificationPreview(` hello\n\n${'x'.repeat(140)} `); + expect(preview).not.toContain('\n'); + expect(preview).toHaveLength(110); + expect(preview.endsWith('...')).toBe(true); + }); +}); + +describe('weeklyPlanningCopy', () => { + it('never prints empty-week zero statistics', () => { + expect(weeklyPlanningCopy(0, 0, 3)).toEqual({ + title: '3 plans match your interests', + body: "See what's forming around campus this week.", + }); + expect(weeklyPlanningCopy(0, 0, 1)?.title).toBe('1 plan matches your interests'); + }); + + it('uses correct singular wording', () => { + expect(weeklyPlanningCopy(1, 1, 1)).toEqual({ + title: 'You made it to 1 plan this week', + body: 'You met 1 new person. 1 more plan matches your interests this week.', + }); + }); + + it('stays silent without a useful next action', () => { + expect(weeklyPlanningCopy(2, 3, 0)).toBeNull(); + expect(weeklyPlanningCopy(0, 0, 0)).toBeNull(); + }); +}); + +describe('podUpdateCopy', () => { + it('names a time-only change', () => { + expect(podUpdateCopy('Pickup Basketball', ['meetupTime'], 'Friday at 7:30 PM', 'RPAC')).toEqual({ + title: 'Pickup Basketball moved to Friday at 7:30 PM', + body: 'Friday at 7:30 PM · RPAC', + }); + }); +}); + +describe('clubRoleChangeCopy', () => { + it.each([ + ['OWNER', "You're now the owner of Coding Club"], + ['ADMIN', "You're now an admin in Coding Club"], + ['OFFICER', "You're now an officer in Coding Club"], + ['MEMBER', "You're now a member in Coding Club"], + ])('uses the real %s role label', (role, title) => { + expect(clubRoleChangeCopy('Coding Club', role).title).toBe(title); + }); +}); + +describe('transactional copy', () => { + it('keeps invitations and time-sensitive prompts specific and actionable', () => { + expect(friendRequestCopy('Maya')).toEqual({ + title: 'Maya sent you a friend request', + body: 'Open your Inbox to respond.', + }); + expect(waitlistTwinCopy('Coffee Crawl', 'Saturday at 11:00 AM', 'Short North')).toEqual({ + title: 'Another Coffee Crawl opened', + body: 'Saturday at 11:00 AM · Short North · You have first look.', + }); + expect(firstPlanNudgeCopy('Coffee Crawl', '11:00 AM', 'Short North', 'Maya is going')).toEqual({ + title: 'Your first Oval plan is today', + body: 'Coffee Crawl · 11:00 AM at Short North · Maya is going', + }); + }); + + it('keeps discovery and club copy concise', () => { + expect(demandPlanCreatedCopy('Pickup Soccer', 'tomorrow', '7:00 PM', 'Lincoln Tower Park')).toEqual({ + title: 'Pickup Soccer is happening tomorrow', + body: '7:00 PM at Lincoln Tower Park. Join while spots are open.', + }); + expect(clubAttendanceOpenCopy('Weekly meeting', 'Oval Builders')).toEqual({ + title: 'Check in to Weekly meeting', + body: 'Oval Builders is meeting now.', + }); + expect(clubOutreachCopy('Oval Builders', ' Room changed\n to Enarson 204 ')).toEqual({ + title: 'Message from Oval Builders', + body: 'Room changed to Enarson 204', + }); + }); +}); diff --git a/backend/src/lib/notificationCopy.ts b/backend/src/lib/notificationCopy.ts new file mode 100644 index 0000000..4d68a44 --- /dev/null +++ b/backend/src/lib/notificationCopy.ts @@ -0,0 +1,266 @@ +export interface NotificationCopy { + title: string; + body: string; +} + +export const PUSH_CHANNELS = { + plans: 'plans', + messages: 'messages', + clubs: 'clubs', + discovery: 'discovery', +} as const; + +export function pluralize(count: number, singular: string, plural = `${singular}s`): string { + return count === 1 ? singular : plural; +} + +export function notificationPreview(content: string | null | undefined, fallback = 'Sent a message'): string { + const normalized = content?.replace(/\s+/g, ' ').trim() || fallback; + return normalized.length > 110 ? `${normalized.slice(0, 107)}...` : normalized; +} + +export function podJoinCopy( + joinerName: string, + planTitle: string, + memberCount: number, + maxMembers: number, + when: string, +): NotificationCopy { + return { + title: `${joinerName} joined ${planTitle}`, + body: `${memberCount} of ${maxMembers} spots filled${when ? ` · ${when}` : ''}`, + }; +} + +export function podInviteCopy( + senderName: string, + planTitle: string, + when: string, + location: string, + spotsLeft: number, +): NotificationCopy { + const details = [when, location, `${spotsLeft} ${pluralize(spotsLeft, 'spot')} left`].filter(Boolean); + return { + title: `${senderName} invited you to ${planTitle}`, + body: details.join(' · '), + }; +} + +export function podUpdateCopy( + planTitle: string, + changedFields: string[], + when: string, + location: string, +): NotificationCopy { + const changedTime = changedFields.includes('meetupTime'); + const changedLocation = changedFields.includes('location'); + const title = + changedTime && !changedLocation + ? `${planTitle} moved to ${when}` + : changedLocation && !changedTime + ? `New location for ${planTitle}` + : `${planTitle} was updated`; + return { title, body: [when, location].filter(Boolean).join(' · ') }; +} + +export function podCancelledCopy(planTitle: string, when: string): NotificationCopy { + return { + title: `${planTitle} was canceled`, + body: when ? `${when} is no longer happening.` : 'This plan is no longer happening.', + }; +} + +export function podMessageCopy(senderName: string, planTitle: string, preview?: string): NotificationCopy { + return { + title: `${senderName} · ${planTitle}`, + body: notificationPreview(preview), + }; +} + +export function directMessageCopy(senderName: string, preview?: string): NotificationCopy { + return { title: senderName, body: notificationPreview(preview) }; +} + +export function friendRequestCopy(senderName: string): NotificationCopy { + return { + title: `${senderName} sent you a friend request`, + body: 'Open your Inbox to respond.', + }; +} + +export function meetupReminderCopy( + planTitle: string, + time: string, + location: string, + memberCount: number, +): NotificationCopy { + return { + title: `${planTitle} starts in 1 hour`, + body: `${time} at ${location} · ${memberCount} ${pluralize(memberCount, 'person', 'people')} going`, + }; +} + +export function recapPromptCopy(planTitle: string): NotificationCopy { + return { + title: `How was ${planTitle}?`, + body: 'Tap to rate it—it takes about 10 seconds.', + }; +} + +export function waitlistSpotCopy(planTitle: string): NotificationCopy { + return { + title: `A spot opened in ${planTitle}`, + body: 'Join within 30 minutes before it goes to the next person.', + }; +} + +export function waitlistTwinCopy( + planTitle: string, + when: string, + location: string, +): NotificationCopy { + return { + title: `Another ${planTitle} opened`, + body: [when, location, 'You have first look.'].filter(Boolean).join(' · '), + }; +} + +export function firstPlanNudgeCopy( + planTitle: string, + time: string, + location: string, + peopleLine: string, +): NotificationCopy { + return { + title: 'Your first Oval plan is today', + body: `${planTitle} · ${time} at ${location} · ${peopleLine}`, + }; +} + +export function demandConversionCopy(peopleCount: number, activityTitle: string): NotificationCopy { + return { + title: `${peopleCount} people are up for ${activityTitle}`, + body: 'Pick a time and start the plan.', + }; +} + +export function demandPlanCreatedCopy( + planTitle: string, + dayLabel: string, + time?: string, + location?: string, +): NotificationCopy { + return { + title: `${planTitle} is happening ${dayLabel}`, + body: time && location + ? `${time} at ${location}. Join while spots are open.` + : 'Join while spots are open.', + }; +} + +export function weeklyPlanningCopy( + attendedCount: number, + newPeopleCount: number, + joinableCount: number, +): NotificationCopy | null { + // A push should always offer a useful next action. Retrospective-only and + // completely empty weeks stay silent. + if (joinableCount === 0) return null; + if (attendedCount === 0) { + return { + title: `${joinableCount} ${pluralize(joinableCount, 'plan')} ${joinableCount === 1 ? 'matches' : 'match'} your interests`, + body: "See what's forming around campus this week.", + }; + } + + const socialLine = newPeopleCount > 0 + ? `You met ${newPeopleCount} new ${pluralize(newPeopleCount, 'person', 'people')}. ` + : ''; + return { + title: `You made it to ${attendedCount} ${pluralize(attendedCount, 'plan')} this week`, + body: `${socialLine}${joinableCount} more ${pluralize(joinableCount, 'plan')} ${joinableCount === 1 ? 'matches' : 'match'} your interests this week.`, + }; +} + +export function clubMessageCopy( + senderName: string, + clubName: string, + channelName: string, + preview: string, + mentionLabel?: string, +): NotificationCopy { + return mentionLabel + ? { + title: `${senderName} mentioned ${mentionLabel}`, + body: `${clubName} · #${channelName}: ${notificationPreview(preview)}`, + } + : { + title: `${senderName} · ${clubName} #${channelName}`, + body: notificationPreview(preview), + }; +} + +export function clubRoleChangeCopy(clubName: string, newRole: string): NotificationCopy { + const roleLabels: Record = { + OWNER: 'owner', + ADMIN: 'admin', + OFFICER: 'officer', + MEMBER: 'member', + }; + const role = roleLabels[newRole] ?? newRole.toLowerCase(); + return { + title: role === 'owner' + ? `You're now the owner of ${clubName}` + : `You're now an ${role} in ${clubName}`.replace('an member', 'a member'), + body: role === 'member' + ? 'Your club permissions have been updated.' + : 'Open the club to see your updated permissions.', + }; +} + +export function clubMeetingCreatedCopy( + clubName: string, + meetingTitle: string, + meetingDate: string, + location: string, +): NotificationCopy { + return { + title: `${clubName} added ${meetingTitle}`, + body: `${meetingDate} · ${location}`, + }; +} + +export function clubAnnouncementCopy(clubName: string, content: string): NotificationCopy { + return { + title: `New from ${clubName}`, + body: notificationPreview(content, 'A new announcement was posted.'), + }; +} + +export function clubRemovalCopy(clubName: string): NotificationCopy { + return { + title: `You're no longer in ${clubName}`, + body: 'Your club membership was updated.', + }; +} + +export function clubAttendanceOpenCopy(meetingTitle: string, clubName: string): NotificationCopy { + return { + title: `Check in to ${meetingTitle}`, + body: `${clubName} is meeting now.`, + }; +} + +export function clubRsvpCopy(meetingTitle: string, clubName: string, meetingDate: string): NotificationCopy { + return { + title: `Are you going to ${meetingTitle}?`, + body: `${clubName} · ${meetingDate}`, + }; +} + +export function clubOutreachCopy(clubName: string, content: string): NotificationCopy { + return { + title: `Message from ${clubName}`, + body: notificationPreview(content, 'Your club shared an update.'), + }; +} diff --git a/backend/src/lib/notificationPreferences.ts b/backend/src/lib/notificationPreferences.ts new file mode 100644 index 0000000..91f23ee --- /dev/null +++ b/backend/src/lib/notificationPreferences.ts @@ -0,0 +1,50 @@ +export interface NotificationPreferences { + podJoin: boolean; + podInvite: boolean; + friendRequest: boolean; + newMessage: boolean; + meetupReminder: boolean; + recapPrompt: boolean; + waitlistSpot: boolean; + clubMeetingCreated: boolean; + clubAnnouncementCreated: boolean; + clubKick: boolean; + clubRoleChange: boolean; + clubAttendanceOpen: boolean; + clubRsvpReminder: boolean; + clubOutreach: boolean; + clubRolePing: boolean; + weeklyRecap: boolean; + demandAlerts: boolean; +} + +export const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = { + podJoin: true, + podInvite: true, + friendRequest: true, + newMessage: true, + meetupReminder: true, + recapPrompt: true, + waitlistSpot: true, + clubMeetingCreated: true, + clubAnnouncementCreated: true, + clubKick: true, + clubRoleChange: true, + clubAttendanceOpen: true, + clubRsvpReminder: true, + clubOutreach: true, + clubRolePing: true, + weeklyRecap: true, + demandAlerts: true, +}; + +export function parseNotificationPreferences( + raw: string | null | undefined, +): NotificationPreferences { + if (!raw) return { ...DEFAULT_NOTIFICATION_PREFERENCES }; + try { + return { ...DEFAULT_NOTIFICATION_PREFERENCES, ...JSON.parse(raw) }; + } catch { + return { ...DEFAULT_NOTIFICATION_PREFERENCES }; + } +} diff --git a/backend/src/lib/realtime.test.ts b/backend/src/lib/realtime.test.ts new file mode 100644 index 0000000..329752c --- /dev/null +++ b/backend/src/lib/realtime.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { clubChannelTopic, createBroadcastRequest } from './realtime'; + +describe('realtime broadcast requests', () => { + it('uses apikey authentication without treating opaque secret keys as JWTs', () => { + const request = createBroadcastRequest( + 'sb_secret_test', + 'dm-thread-1', + 'typing', + { userId: 'user-1' }, + ); + const headers = request.headers as Record; + + expect(headers.apikey).toBe('sb_secret_test'); + expect(headers.Authorization).toBeUndefined(); + }); + + it('builds the batch payload expected by Supabase Realtime', () => { + const request = createBroadcastRequest( + 'legacy-service-role-jwt', + 'pod-1', + 'new_message', + { userId: 'user-1' }, + ); + + expect(JSON.parse(String(request.body))).toEqual({ + messages: [ + { + topic: 'pod-1', + event: 'new_message', + payload: { userId: 'user-1' }, + private: false, + }, + ], + }); + }); + + it('creates stable channel-specific club topics', () => { + expect(clubChannelTopic('club-1', 'channel-2')).toBe('club-club-1-channel-channel-2'); + }); +}); diff --git a/backend/src/lib/realtime.ts b/backend/src/lib/realtime.ts index 370f15e..092ff95 100644 --- a/backend/src/lib/realtime.ts +++ b/backend/src/lib/realtime.ts @@ -19,8 +19,11 @@ export const REALTIME_EVENTS = { MESSAGE_UPDATE: 'message_update', TYPING: 'typing', INBOX_UPDATED: 'inbox_updated', + STATE_UPDATED: 'state_updated', } as const; +export const SHARED_STATE_TOPIC = 'app-state'; + export function podTopic(podId: string): string { return `pod-${podId}`; } @@ -33,6 +36,31 @@ export function userTopic(userId: string): string { return `user-${userId}`; } +export function clubChannelTopic(clubId: string, channelId: string): string { + return `club-${clubId}-channel-${channelId}`; +} + +export function createBroadcastRequest( + key: string, + topic: string, + event: string, + payload: Record, +): RequestInit { + return { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + // Supabase's current publishable/secret keys are opaque rather than JWTs. + // Sending an sb_secret_* key as a Bearer token returns 401, while the + // apikey header works for both current keys and legacy service_role JWTs. + apikey: key, + }, + body: JSON.stringify({ + messages: [{ topic, event, payload, private: false }], + }), + }; +} + export async function broadcast( topic: string, event: string, @@ -43,22 +71,22 @@ export async function broadcast( if (!url || !key) return; if (process.env.NODE_ENV === 'test') return; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 1_500); try { - const response = await fetch(`${url}/realtime/v1/api/broadcast`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - apikey: key, - Authorization: `Bearer ${key}`, + const response = await fetch( + `${url}/realtime/v1/api/broadcast`, + { + ...createBroadcastRequest(key, topic, event, payload), + signal: controller.signal, }, - body: JSON.stringify({ - messages: [{ topic, event, payload, private: false }], - }), - }); + ); if (!response.ok) { console.error(`[realtime] broadcast failed: ${response.status}`); } } catch (err) { console.error('[realtime] broadcast failed:', err); + } finally { + clearTimeout(timeoutId); } } diff --git a/backend/src/middleware/sharedStateInvalidation.test.ts b/backend/src/middleware/sharedStateInvalidation.test.ts new file mode 100644 index 0000000..7d33f10 --- /dev/null +++ b/backend/src/middleware/sharedStateInvalidation.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { sharedStateScopesForRequest } from './sharedStateInvalidation'; + +describe('shared state invalidation classification', () => { + it('maps shared resource mutations to the screens that consume them', () => { + expect(sharedStateScopesForRequest('PATCH', '/pods/pod-1')).toEqual(['pods']); + expect(sharedStateScopesForRequest('POST', '/clubs/club-1/meetings')).toEqual(['clubs']); + expect(sharedStateScopesForRequest('POST', '/friends/requests')).toEqual([ + 'friends', + 'users', + 'inbox', + ]); + expect(sharedStateScopesForRequest('PATCH', '/users/me')).toEqual([ + 'users', + 'friends', + 'pods', + 'clubs', + ]); + expect(sharedStateScopesForRequest('POST', '/activities/activity-1/demand')).toEqual([ + 'activities', + 'pods', + ]); + }); + + it('does not broadcast for reads, errors-to-be-decided later, or private noise', () => { + expect(sharedStateScopesForRequest('GET', '/pods/pod-1')).toEqual([]); + expect(sharedStateScopesForRequest('POST', '/pods/pod-1/typing')).toEqual([]); + expect(sharedStateScopesForRequest('PATCH', '/messages/threads/thread-1/read')).toEqual(['messages']); + expect(sharedStateScopesForRequest('POST', '/users/push-token')).toEqual([]); + expect(sharedStateScopesForRequest('PATCH', '/users/notifications')).toEqual([]); + }); + + it('refreshes message metadata without reloading unrelated resource feeds', () => { + expect(sharedStateScopesForRequest('POST', '/messages/threads/thread-1/messages')).toEqual(['messages']); + expect(sharedStateScopesForRequest('POST', '/pods/pod-1/messages')).toEqual(['messages']); + expect( + sharedStateScopesForRequest( + 'DELETE', + '/clubs/club-1/channels/channel-1/messages/message-1', + ), + ).toEqual(['messages']); + }); +}); diff --git a/backend/src/middleware/sharedStateInvalidation.ts b/backend/src/middleware/sharedStateInvalidation.ts new file mode 100644 index 0000000..9c17bea --- /dev/null +++ b/backend/src/middleware/sharedStateInvalidation.ts @@ -0,0 +1,115 @@ +import type { RequestHandler, Response } from 'express'; +import { + broadcast, + REALTIME_EVENTS, + SHARED_STATE_TOPIC, + userTopic, +} from '../lib/realtime'; +import type { AuthRequest } from './auth'; + +export const SHARED_STATE_SCOPES = [ + 'pods', + 'clubs', + 'friends', + 'users', + 'activities', + 'messages', + 'inbox', + 'admin', +] as const; + +export type SharedStateScope = (typeof SHARED_STATE_SCOPES)[number]; + +const MUTATION_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); + +/** + * Converts a successful API mutation into a content-free invalidation ping. + * Chat, read receipts, typing, analytics, auth, and private device settings + * have dedicated behavior and intentionally do not wake unrelated screens. + */ +export function sharedStateScopesForRequest( + method: string, + originalUrl: string, +): SharedStateScope[] { + if (!MUTATION_METHODS.has(method.toUpperCase())) return []; + + const path = originalUrl.split('?')[0].replace(/\/+$/, '') || '/'; + + if ( + path.startsWith('/auth') + || path.startsWith('/analytics') + || path.startsWith('/cron') + || path.startsWith('/waitlist') + || path.startsWith('/reports') + || path.endsWith('/typing') + || path === '/users/push-token' + || path === '/users/notifications' + ) { + return []; + } + + // Room-level topics update the open chat itself. This broader scope updates + // unread counts, previews, and inbox rows elsewhere without reloading feeds. + if ( + path.startsWith('/messages') + || (/^\/pods\/[^/]+\/messages(?:\/|$)/).test(path) + || (/^\/clubs\/[^/]+\/(?:messages|officer-messages)(?:\/|$)/).test(path) + || (/^\/clubs\/[^/]+\/channels\/[^/]+\/(?:messages|typing|read)(?:\/|$)/).test(path) + ) { + return ['messages']; + } + + if (path.startsWith('/pods')) return ['pods']; + if (path.startsWith('/clubs')) return ['clubs']; + if (path.startsWith('/friends')) return ['friends', 'users', 'inbox']; + if (path.startsWith('/users')) return ['users', 'friends', 'pods', 'clubs']; + if (path.startsWith('/activities')) return ['activities', 'pods']; + if (path.startsWith('/admin')) return ['admin', 'activities', 'clubs']; + return []; +} + +/** + * Waits for Supabase to accept the ping before ending the response. This is + * important on Vercel, where background work may be frozen as soon as the + * serverless response completes. + */ +export const sharedStateInvalidation: RequestHandler = (req, res, next) => { + const scopes = sharedStateScopesForRequest(req.method, req.originalUrl || req.url); + if (!scopes.length) { + next(); + return; + } + + const originalEnd = res.end; + let ending = false; + + res.end = function delayedEnd(...args: unknown[]) { + if (ending || res.statusCode < 200 || res.statusCode >= 400) { + return originalEnd.apply(res, args as Parameters); + } + + ending = true; + const broadcasts = [ + broadcast( + SHARED_STATE_TOPIC, + REALTIME_EVENTS.STATE_UPDATED, + { scopes }, + ), + ]; + const actorUserId = (req as AuthRequest).user?.userId; + if (actorUserId && scopes.includes('users')) { + broadcasts.push(broadcast( + userTopic(actorUserId), + REALTIME_EVENTS.STATE_UPDATED, + { scopes: ['users'] }, + )); + } + + void Promise.all(broadcasts).finally(() => { + originalEnd.apply(res, args as Parameters); + }); + return res; + } as Response['end']; + + next(); +}; diff --git a/backend/src/routes/clubs.ts b/backend/src/routes/clubs.ts index 8c2b09e..63b8fd0 100644 --- a/backend/src/routes/clubs.ts +++ b/backend/src/routes/clubs.ts @@ -12,6 +12,7 @@ import { isSupabaseStorageConfigured, supabaseStorage } from '../lib/supabaseSto import { moderateImageContent, moderateTextContent } from '../lib/contentModeration'; import { consumeDurableRateLimit } from '../lib/durableRateLimit'; import { isValidReactionEmoji } from '../lib/reactionEmojis'; +import { broadcast, clubChannelTopic, REALTIME_EVENTS } from '../lib/realtime'; // ── Club avatar upload setup ────────────────────────────────────────────────── @@ -2433,6 +2434,10 @@ router.post('/:id/messages', requireAuth, async (req: AuthRequest, res: Response }, }); + void NotificationService.notifyClubMessage(clubId, userId, trimmed, { + channelKind: CHANNEL_GENERAL, + }); + res.status(201).json(message); } catch (err) { console.error(err); @@ -2560,6 +2565,10 @@ router.post('/:id/officer-messages', requireAuth, async (req: AuthRequest, res: include: { user: { select: { id: true, name: true, avatarUrl: true } } }, }); + void NotificationService.notifyClubMessage(clubId, userId, content.trim(), { + channelKind: CHANNEL_OFFICERS, + }); + res.status(201).json(message); } catch (err) { console.error(err); @@ -3127,16 +3136,12 @@ router.post('/:id/channels/:channelId/messages', requireAuth, async (req: AuthRe create: { channelId: channel.id, userId }, }); - if (pingRoleIds.length > 0) { - void NotificationService.notifyClubRolePing( - clubId, - userId, - pingRoleIds, - channel.name, - trimmed || 'Shared a photo', - ); - } + void NotificationService.notifyClubMessage(clubId, userId, trimmed || 'Shared a photo', { + channelId: channel.id, + mentionedRoleIds: pingRoleIds, + }); + await broadcast(clubChannelTopic(clubId, channel.id), REALTIME_EVENTS.NEW_MESSAGE, { userId }); res.status(201).json(message); } catch (err) { console.error(err); @@ -3199,6 +3204,7 @@ router.post( }, }, }); + await broadcast(clubChannelTopic(clubId, channel.id), REALTIME_EVENTS.MESSAGE_UPDATE, { userId }); res.json({ ...updated, mentionRoleIds: parseStringList(updated.mentionRoleIds) }); } catch (err) { console.error(err); @@ -3257,6 +3263,7 @@ router.delete( }, }, }); + await broadcast(clubChannelTopic(clubId, channel.id), REALTIME_EVENTS.MESSAGE_UPDATE, { userId }); res.json({ ...updated, mentionRoleIds: parseStringList(updated.mentionRoleIds) }); } catch (err) { console.error(err); @@ -3313,6 +3320,7 @@ router.delete( await prisma.clubMessage.delete({ where: { id: messageId } }); await cleanupClubMessageImageUrl(message.imageUrl); } + await broadcast(clubChannelTopic(clubId, channel.id), REALTIME_EVENTS.MESSAGE_UPDATE, { userId }); res.json({ ok: true }); } catch (err) { console.error(err); @@ -3341,6 +3349,7 @@ router.post('/:id/channels/:channelId/typing', requireAuth, async (req: AuthRequ const typing = typingScopeForChannel(channel); setTyping(typing.scope, typing.key, userId); + await broadcast(clubChannelTopic(clubId, channel.id), REALTIME_EVENTS.TYPING, { userId }); res.json({ ok: true }); } catch (err) { console.error(err); diff --git a/backend/src/routes/directMessages.ts b/backend/src/routes/directMessages.ts index 9854259..ca3a568 100644 --- a/backend/src/routes/directMessages.ts +++ b/backend/src/routes/directMessages.ts @@ -268,7 +268,7 @@ router.post('/threads/:id/typing', async (req: AuthRequest, res: Response): Prom return; } setTyping('dm', threadId, userId); - void broadcast(dmTopic(threadId), REALTIME_EVENTS.TYPING); + await broadcast(dmTopic(threadId), REALTIME_EVENTS.TYPING, { userId }); res.json({ ok: true }); } catch { res.status(500).json({ error: 'Internal server error' }); @@ -323,7 +323,7 @@ router.post('/threads/:id/messages/:msgId/reactions', async (req: AuthRequest, r create: { directMessageId: msgId, userId, emoji }, update: {}, }); - void broadcast(dmTopic(threadId), REALTIME_EVENTS.MESSAGE_UPDATE); + await broadcast(dmTopic(threadId), REALTIME_EVENTS.MESSAGE_UPDATE, { userId }); const updated = await prisma.directMessage.findUnique({ where: { id: msgId }, @@ -377,7 +377,7 @@ router.delete('/threads/:id/messages/:msgId/reactions', async (req: AuthRequest, await prisma.directMessageReaction.deleteMany({ where: { directMessageId: msgId, userId, emoji }, }); - void broadcast(dmTopic(threadId), REALTIME_EVENTS.MESSAGE_UPDATE); + await broadcast(dmTopic(threadId), REALTIME_EVENTS.MESSAGE_UPDATE, { userId }); const updated = await prisma.directMessage.findUnique({ where: { id: msgId }, @@ -420,7 +420,7 @@ router.delete('/threads/:id/messages/:msgId', async (req: AuthRequest, res: Resp return; } await prisma.directMessage.delete({ where: { id: msgId } }); - void broadcast(dmTopic(threadId), REALTIME_EVENTS.MESSAGE_UPDATE); + await broadcast(dmTopic(threadId), REALTIME_EVENTS.MESSAGE_UPDATE, { userId }); res.status(204).send(); } catch (err) { console.error(err); @@ -511,9 +511,11 @@ router.post('/threads/:id/messages', async (req: AuthRequest, res: Response): Pr }), ]); - void broadcast(dmTopic(threadId), REALTIME_EVENTS.NEW_MESSAGE); - void broadcast(userTopic(otherId), REALTIME_EVENTS.INBOX_UPDATED); - NotificationService.notifyDirectMessage(threadId, userId).catch(() => {}); + await Promise.all([ + broadcast(dmTopic(threadId), REALTIME_EVENTS.NEW_MESSAGE, { userId }), + broadcast(userTopic(otherId), REALTIME_EVENTS.INBOX_UPDATED), + ]); + NotificationService.notifyDirectMessage(threadId, userId, data.content).catch(() => {}); res.status(201).json(formatDmMessage(message)); } catch { diff --git a/backend/src/routes/friends.ts b/backend/src/routes/friends.ts index bc5db85..a39b8f0 100644 --- a/backend/src/routes/friends.ts +++ b/backend/src/routes/friends.ts @@ -5,6 +5,7 @@ import { getBlockedUserIds, hasBlockingRelationship } from '../lib/blocks'; import { normalizeUserPair } from '../lib/friendUtils'; import { withDisplayName } from '../lib/userNames'; import { broadcast, REALTIME_EVENTS, userTopic } from '../lib/realtime'; +import { NotificationService } from '../lib/NotificationService'; import { sendFriendRequest, acceptFriendRequest, @@ -96,7 +97,8 @@ router.post('/requests', async (req: AuthRequest, res: Response): Promise try { const friendRequest = await sendFriendRequest(senderId, receiverId); - void broadcast(userTopic(receiverId), REALTIME_EVENTS.INBOX_UPDATED); + await broadcast(userTopic(receiverId), REALTIME_EVENTS.INBOX_UPDATED); + NotificationService.notifyFriendRequest(friendRequest.id).catch(() => {}); res.status(201).json(friendRequest); } catch (err) { const e = err as Error & { status?: number }; @@ -115,8 +117,10 @@ router.post('/requests/:id/accept', async (req: AuthRequest, res: Response): Pro }); await acceptFriendRequest(id, userId); if (requestRow) { - void broadcast(userTopic(requestRow.senderId), REALTIME_EVENTS.INBOX_UPDATED); - void broadcast(userTopic(requestRow.receiverId), REALTIME_EVENTS.INBOX_UPDATED); + await Promise.all([ + broadcast(userTopic(requestRow.senderId), REALTIME_EVENTS.INBOX_UPDATED), + broadcast(userTopic(requestRow.receiverId), REALTIME_EVENTS.INBOX_UPDATED), + ]); } res.json({ ok: true }); } catch (err) { @@ -131,8 +135,10 @@ router.post('/requests/:id/decline', async (req: AuthRequest, res: Response): Pr const { id } = req.params; try { const requestRow = await declineFriendRequest(id, userId); - void broadcast(userTopic(requestRow.senderId), REALTIME_EVENTS.INBOX_UPDATED); - void broadcast(userTopic(requestRow.receiverId), REALTIME_EVENTS.INBOX_UPDATED); + await Promise.all([ + broadcast(userTopic(requestRow.senderId), REALTIME_EVENTS.INBOX_UPDATED), + broadcast(userTopic(requestRow.receiverId), REALTIME_EVENTS.INBOX_UPDATED), + ]); res.json({ ok: true }); } catch (err) { const e = err as Error & { status?: number }; @@ -146,8 +152,10 @@ router.delete('/requests/:id', async (req: AuthRequest, res: Response): Promise< const { id } = req.params; try { const requestRow = await cancelFriendRequest(id, userId); - void broadcast(userTopic(requestRow.senderId), REALTIME_EVENTS.INBOX_UPDATED); - void broadcast(userTopic(requestRow.receiverId), REALTIME_EVENTS.INBOX_UPDATED); + await Promise.all([ + broadcast(userTopic(requestRow.senderId), REALTIME_EVENTS.INBOX_UPDATED), + broadcast(userTopic(requestRow.receiverId), REALTIME_EVENTS.INBOX_UPDATED), + ]); res.status(204).send(); } catch (err) { const e = err as Error & { status?: number }; diff --git a/backend/src/routes/messages.ts b/backend/src/routes/messages.ts index 233f46c..cbb1961 100644 --- a/backend/src/routes/messages.ts +++ b/backend/src/routes/messages.ts @@ -131,7 +131,7 @@ router.get('/', requireAuth, async (req: AuthRequest, res: Response): Promise {}); + NotificationService.notifyNewMessage(podId, userId, trimmed).catch(() => {}); stampPodReadState(userId, podId).catch(() => {}); - void broadcast(podTopic(podId), REALTIME_EVENTS.NEW_MESSAGE); - for (const member of pod.members) { - if (member.userId !== userId) { - void broadcast(userTopic(member.userId), REALTIME_EVENTS.INBOX_UPDATED); - } - } + await Promise.all([ + broadcast(podTopic(podId), REALTIME_EVENTS.NEW_MESSAGE, { userId }), + ...pod.members + .filter((member) => member.userId !== userId) + .map((member) => broadcast(userTopic(member.userId), REALTIME_EVENTS.INBOX_UPDATED)), + ]); res.status(201).json(formatPodMessage(message)); } catch (err) { @@ -277,7 +277,7 @@ router.post('/:msgId/reactions', requireAuth, async (req: AuthRequest, res: Resp create: { messageId: msgId, userId, emoji }, update: {}, }); - void broadcast(podTopic(podId), REALTIME_EVENTS.MESSAGE_UPDATE); + await broadcast(podTopic(podId), REALTIME_EVENTS.MESSAGE_UPDATE, { userId }); const updated = await prisma.message.findUnique({ where: { id: msgId }, @@ -321,7 +321,7 @@ router.delete('/:msgId', requireAuth, async (req: AuthRequest, res: Response): P return; } await prisma.message.delete({ where: { id: msgId } }); - void broadcast(podTopic(podId), REALTIME_EVENTS.MESSAGE_UPDATE); + await broadcast(podTopic(podId), REALTIME_EVENTS.MESSAGE_UPDATE, { userId }); res.status(204).send(); } catch (err) { console.error(err); @@ -360,7 +360,7 @@ router.delete('/:msgId/reactions', requireAuth, async (req: AuthRequest, res: Re await prisma.messageReaction.deleteMany({ where: { messageId: msgId, userId, emoji }, }); - void broadcast(podTopic(podId), REALTIME_EVENTS.MESSAGE_UPDATE); + await broadcast(podTopic(podId), REALTIME_EVENTS.MESSAGE_UPDATE, { userId }); const updated = await prisma.message.findUnique({ where: { id: msgId }, diff --git a/backend/src/routes/podInvites.ts b/backend/src/routes/podInvites.ts index b42b1c4..4730941 100644 --- a/backend/src/routes/podInvites.ts +++ b/backend/src/routes/podInvites.ts @@ -6,6 +6,7 @@ import { areFriends } from '../lib/friendUtils'; import { joinExistingPodMember, parsePodMembers } from '../lib/joinExistingPod'; import { withDisplayName } from '../lib/userNames'; import { broadcast, REALTIME_EVENTS, userTopic } from '../lib/realtime'; +import { NotificationService } from '../lib/NotificationService'; const router = Router(); @@ -94,7 +95,8 @@ router.post('/:id/invite', requireAuth, async (req: AuthRequest, res: Response): receiver: { select: { id: true, name: true, firstName: true, lastName: true, avatarUrl: true } }, }, }); - void broadcast(userTopic(receiverId), REALTIME_EVENTS.INBOX_UPDATED); + await broadcast(userTopic(receiverId), REALTIME_EVENTS.INBOX_UPDATED); + NotificationService.notifyPodInvite(invite.id).catch(() => {}); res.status(201).json({ ...invite, @@ -250,8 +252,10 @@ router.post('/invites/:id/accept', requireAuth, async (req: AuthRequest, res: Re return; } - void broadcast(userTopic(invite.senderId), REALTIME_EVENTS.INBOX_UPDATED); - void broadcast(userTopic(invite.receiverId), REALTIME_EVENTS.INBOX_UPDATED); + await Promise.all([ + broadcast(userTopic(invite.senderId), REALTIME_EVENTS.INBOX_UPDATED), + broadcast(userTopic(invite.receiverId), REALTIME_EVENTS.INBOX_UPDATED), + ]); res.status(201).json(parsePodMembers(result.updatedPod)); } catch { res.status(500).json({ error: 'Internal server error' }); @@ -279,8 +283,10 @@ router.post('/invites/:id/decline', requireAuth, async (req: AuthRequest, res: R where: { id: inviteId }, data: { status: 'DECLINED', respondedAt: new Date() }, }); - void broadcast(userTopic(invite.senderId), REALTIME_EVENTS.INBOX_UPDATED); - void broadcast(userTopic(invite.receiverId), REALTIME_EVENTS.INBOX_UPDATED); + await Promise.all([ + broadcast(userTopic(invite.senderId), REALTIME_EVENTS.INBOX_UPDATED), + broadcast(userTopic(invite.receiverId), REALTIME_EVENTS.INBOX_UPDATED), + ]); res.status(204).send(); } catch { diff --git a/backend/src/routes/pods.ts b/backend/src/routes/pods.ts index c3e0f8f..feebeef 100644 --- a/backend/src/routes/pods.ts +++ b/backend/src/routes/pods.ts @@ -961,7 +961,13 @@ router.patch('/:id', requireAuth, async (req: AuthRequest, res: Response): Promi }, }); - NotificationService.notifyPodPlanChange(id, userId, 'updated').catch(() => {}); + NotificationService.notifyPodPlanChange( + id, + userId, + 'updated', + undefined, + Object.keys(data), + ).catch(() => {}); res.json(parsePodMembers(updated)); } catch (err) { @@ -1095,7 +1101,7 @@ router.post('/:id/typing', requireAuth, async (req: AuthRequest, res: Response): return; } setTyping('pod', podId, userId); - void broadcast(podTopic(podId), REALTIME_EVENTS.TYPING); + await broadcast(podTopic(podId), REALTIME_EVENTS.TYPING, { userId }); res.json({ ok: true }); } catch (err) { console.error(err); diff --git a/backend/src/routes/users.test.ts b/backend/src/routes/users.test.ts index 52b710e..05d8f43 100644 --- a/backend/src/routes/users.test.ts +++ b/backend/src/routes/users.test.ts @@ -250,8 +250,14 @@ describe('GET /users/notifications', () => { expect(res.body.preferences).toMatchObject({ podJoin: true, + podInvite: true, + friendRequest: true, newMessage: true, meetupReminder: true, + recapPrompt: true, + clubRsvpReminder: true, + clubOutreach: true, + clubRolePing: true, }); }); diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index df9f8b9..163bbe0 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -18,41 +18,21 @@ import { INTEREST_TAG_SET } from '../config/interestTags'; import { getFullName, getPublicName, withDisplayName } from '../lib/userNames'; import { moderateImageContent, moderateTextContent } from '../lib/contentModeration'; import { isPlatformAdmin } from '../middleware/admin'; +import { + DEFAULT_NOTIFICATION_PREFERENCES, + parseNotificationPreferences, +} from '../lib/notificationPreferences'; const VALID_CLASS_YEARS = ['Freshman', 'Sophomore', 'Junior', 'Senior', 'Grad'] as const; const MAJOR_REGEX = /^[a-zA-Z\s&\/\-,\.\(\)]+$/; const INSTAGRAM_REGEX = /^[a-zA-Z0-9._]{1,30}$/; const CLUB_REGEX = /^[a-zA-Z\s&\-]+$/; const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; -const DEFAULT_NOTIFICATION_PREFERENCES = { - podJoin: true, - newMessage: true, - meetupReminder: true, - recapPrompt: true, - waitlistSpot: true, - clubMeetingCreated: true, - clubAnnouncementCreated: true, - clubKick: true, - clubRoleChange: true, - clubAttendanceOpen: true, - weeklyRecap: true, - demandAlerts: true, -}; - function parseJsonArray(raw: string | null | undefined): string[] { if (!raw) return []; try { return JSON.parse(raw); } catch { return []; } } -function parseNotificationPreferences(raw: string | null | undefined) { - if (!raw) return DEFAULT_NOTIFICATION_PREFERENCES; - try { - return { ...DEFAULT_NOTIFICATION_PREFERENCES, ...JSON.parse(raw) }; - } catch { - return DEFAULT_NOTIFICATION_PREFERENCES; - } -} - const router = Router(); // ── Avatar upload setup ──────────────────────────────────────────────────────── @@ -753,6 +733,8 @@ router.patch('/notifications', requireVerifiedAuth, async (req: AuthRequest, res const userId = req.user!.userId; const { podJoin, + podInvite, + friendRequest, newMessage, meetupReminder, recapPrompt, @@ -762,12 +744,17 @@ router.patch('/notifications', requireVerifiedAuth, async (req: AuthRequest, res clubKick, clubRoleChange, clubAttendanceOpen, + clubRsvpReminder, + clubOutreach, + clubRolePing, weeklyRecap, demandAlerts, } = req.body; if ( (podJoin !== undefined && typeof podJoin !== 'boolean') || + (podInvite !== undefined && typeof podInvite !== 'boolean') || + (friendRequest !== undefined && typeof friendRequest !== 'boolean') || (newMessage !== undefined && typeof newMessage !== 'boolean') || (meetupReminder !== undefined && typeof meetupReminder !== 'boolean') || (recapPrompt !== undefined && typeof recapPrompt !== 'boolean') || @@ -777,6 +764,9 @@ router.patch('/notifications', requireVerifiedAuth, async (req: AuthRequest, res (clubKick !== undefined && typeof clubKick !== 'boolean') || (clubRoleChange !== undefined && typeof clubRoleChange !== 'boolean') || (clubAttendanceOpen !== undefined && typeof clubAttendanceOpen !== 'boolean') || + (clubRsvpReminder !== undefined && typeof clubRsvpReminder !== 'boolean') || + (clubOutreach !== undefined && typeof clubOutreach !== 'boolean') || + (clubRolePing !== undefined && typeof clubRolePing !== 'boolean') || (weeklyRecap !== undefined && typeof weeklyRecap !== 'boolean') || (demandAlerts !== undefined && typeof demandAlerts !== 'boolean') ) { @@ -795,6 +785,8 @@ router.patch('/notifications', requireVerifiedAuth, async (req: AuthRequest, res ...DEFAULT_NOTIFICATION_PREFERENCES, ...current, ...(podJoin !== undefined && { podJoin }), + ...(podInvite !== undefined && { podInvite }), + ...(friendRequest !== undefined && { friendRequest }), ...(newMessage !== undefined && { newMessage }), ...(meetupReminder !== undefined && { meetupReminder }), ...(recapPrompt !== undefined && { recapPrompt }), @@ -804,6 +796,9 @@ router.patch('/notifications', requireVerifiedAuth, async (req: AuthRequest, res ...(clubKick !== undefined && { clubKick }), ...(clubRoleChange !== undefined && { clubRoleChange }), ...(clubAttendanceOpen !== undefined && { clubAttendanceOpen }), + ...(clubRsvpReminder !== undefined && { clubRsvpReminder }), + ...(clubOutreach !== undefined && { clubOutreach }), + ...(clubRolePing !== undefined && { clubRolePing }), ...(weeklyRecap !== undefined && { weeklyRecap }), ...(demandAlerts !== undefined && { demandAlerts }), }; diff --git a/backend/src/server.ts b/backend/src/server.ts index b47537c..f108cf5 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -12,6 +12,7 @@ import crypto from 'crypto'; import jwt from 'jsonwebtoken'; import { getJwtSecret } from './config/jwt'; import { inputGuard } from './middleware/inputGuard'; +import { sharedStateInvalidation } from './middleware/sharedStateInvalidation'; import prisma from './prisma'; import authRoutes from './routes/auth'; @@ -94,6 +95,36 @@ app.use( ); app.options('*', cors()); +// Redact user coordinates from logged URLs. +// +// Home/Explore pass the device's lat/lng as query params purely to sort pods by +// proximity — the values are used for that one request and never persisted (no +// lat/lng column exists on User). Morgan's default `url` token logs the full +// query string, which would park precise coordinates in retained Vercel logs and +// make the collection non-ephemeral under Google Play's Data Safety definition. +// Overriding the token keeps the request path (still useful for debugging) while +// dropping the coordinates. +const REDACTED_QUERY_PARAMS = ['lat', 'lng']; +morgan.token('url', (req: express.Request) => { + const original = req.originalUrl || req.url || ''; + const queryStart = original.indexOf('?'); + if (queryStart === -1) return original; + + const path = original.slice(0, queryStart); + const params = new URLSearchParams(original.slice(queryStart + 1)); + let changed = false; + for (const key of REDACTED_QUERY_PARAMS) { + if (params.has(key)) { + params.set(key, 'REDACTED'); + changed = true; + } + } + if (!changed) return original; + + const query = params.toString(); + return query ? `${path}?${query}` : path; +}); + // HTTP request logging — skip in test to keep output clean if (process.env.NODE_ENV !== 'test') { app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev')); @@ -113,6 +144,10 @@ app.use(express.json({ limit: '64kb' })); // keys, nested query objects) before they reach handlers or Prisma. app.use(inputGuard); +// Content-free realtime invalidations for successful shared-state mutations. +// Mounted once so new pod/club/friend routes cannot silently become stale. +app.use(sharedStateInvalidation); + const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 20, diff --git a/docs/EBLC_APPLICATION_2026-08.md b/docs/EBLC_APPLICATION_2026-08.md new file mode 100644 index 0000000..690a801 --- /dev/null +++ b/docs/EBLC_APPLICATION_2026-08.md @@ -0,0 +1,98 @@ +# EBLC Intake — Draft Answers (August 2026) + +Fill in `[BRACKETS]` before submitting. Everything else is copy-paste ready. + +--- + +## Founder contact + +- **First / Last:** Brady / VanBibber +- **Email:** vanbibber.21@osu.edu (Type: Home or Other — set as Primary) +- **Address:** `[your Columbus address]`, Columbus, OH `[zip]`, United States (Type: Home, Primary) +- **Phone:** `[your cell]` (Type: Mobile, Primary) + +## Co-founder + +Leave blank — solo founder. In "Please list any additional co-founders," write: + +> None. I am the sole founder and sole developer. + +--- + +## Venture name + +> Oval. The app is published on the Apple App Store as "Oval." The legal entity is `[EXACT LLC NAME AS FILED]`, an Ohio limited liability company. The product was originally developed under the name "Bridge," and that earlier name still appears in some internal code and documentation, but it is not used publicly. + +## State(s) of operation + +> Ohio. The LLC is formed in Ohio and the venture operates entirely in Columbus, Ohio, serving Ohio State University students. + +## Primary place of business + +> `[your Columbus address]`, Columbus, OH `[zip]`. This is my home address; the venture has no separate office. + +## Website + +> https://theovalapp.com + +## Category + +**For-Profit (LLC, Corporation, or similar)** + +--- + +## Tell us about your venture + +> Oval is an iOS app that helps Ohio State students turn loose intentions into real in-person plans. The core object is a "pod" — a small, time-boxed hangout tied to a specific activity (pickup frisbee, a dining hall dinner, a study session at Thompson, a lift at the RPAC). A student creates a pod with a time, a location, and a size cap; other students browse open pods on a campus feed and map, join one, coordinate in a group chat, and show up. +> +> Around that core, the app includes club discovery (public and private clubs, meetings, announcements, club chat, and officer tools for student organizations), direct messaging, a friend graph with invites and post-hangout "people you met" flows, and push notifications for reminders and check-ins. +> +> The mission is to lower the activation energy for real-world social connection on a campus of 60,000+ students. Large universities are paradoxically isolating: students are surrounded by people but lack a low-stakes way to convert "I'd like to do something tonight" into an actual plan with actual people. Existing tools are either broadcast-oriented (Instagram, GroupMe) or built for strangers dating rather than for classmates hanging out. Oval is deliberately small-group, campus-gated, and plan-shaped. +> +> Trust and safety are core to the product rather than bolted on. Every account requires a verified osu.edu email address before it can see or do anything meaningful. The app has blocking, reporting, no-show tracking, account deletion, and user data export, and carries a 17+/18+ age rating so that it is used only by adult students. Those features exist both because the product doesn't work without trust and because Apple's review process requires them. +> +> Oval is live on the App Store (version 1.1 shipped in July 2026) and is currently free to users with no advertising and no paid tier. + +## Stage of development + +> Live and in market, pre-revenue. The app launched on the Apple App Store in `[MONTH]` 2026 and shipped version 1.1 in July 2026. Real Ohio State students have created accounts, verified their osu.edu addresses, formed pods, and met up in person. `[Optional and worth including if you have it: "As of August 2026 we have roughly ___ verified users and ___ pods created."]` +> +> The venture has made no sales and generated no revenue. It has not hired any employees or contractors — I am the sole developer, designer, and operator, and I have written all of the code myself. I have done direct outreach to Ohio State student organizations about onboarding their clubs onto the platform, and I am preparing for a larger push at the start of the Fall 2026 semester, which is the natural growth moment for a campus product. +> +> The immediate roadmap is Fall 2026 semester launch activity: club onboarding, an Android/Google Play release, and growth experiments around the invite loop. + +## How is or will the venture be funded + +> Entirely self-funded to date. I have personally covered all costs out of pocket: the Apple Developer Program membership, the domain, and hosting and infrastructure (currently on free or low-cost tiers from Vercel, Supabase, and Expo). Because I built the product myself, the dominant input has been my own time rather than cash, and total out-of-pocket spend to date is a few hundred dollars. +> +> I have not borrowed funds, raised investment, or taken on any outside capital, and no one else holds an ownership interest. Going forward I expect to continue bootstrapping in the near term, and I am open to Ohio State–affiliated startup grants and pitch competitions. If the product shows strong retention over the Fall 2026 semester, I may explore pre-seed investment or a revenue model built around university and student-organization tools — but nothing along those lines is underway today. + +## Goals for working with the EBLC + +> Two priorities, in order: +> +> **1. Terms of Service and Privacy Policy review.** This is my most pressing need. Oval is a consumer social app used by real students, and I currently operate under a Terms of Service and Privacy Policy that I drafted and versioned myself, with a version-acceptance gate in the app. I would like a lawyer to review both documents and tell me where I am exposed. Specific concerns: (a) whether my limitation-of-liability, disclaimer, and user-conduct provisions are adequate for a product whose entire purpose is getting people to meet in person; (b) whether my privacy disclosures accurately describe what I actually collect (osu.edu email, profile data, messages, approximate meetup locations, push tokens, and product analytics) and whether they satisfy Apple's App Store privacy requirements and applicable state privacy law; (c) whether my age gating, blocking, reporting, and moderation terms are sufficient; and (d) whether my account-deletion and data-export flows line up with what my policy promises. I would also value guidance on how to handle terms updates and re-acceptance as the product changes. +> +> **2. Entity and governance cleanup following formation.** I formed an Ohio LLC but did the filing myself and want to make sure the foundation is actually sound. I would like help with a single-member operating agreement, confirming that all intellectual property — the source code, the app, the brand, and the domain — is properly owned by the LLC rather than sitting with me personally by default, and understanding the practical steps that make the liability shield real (separate accounts, proper contracting in the entity's name, records to maintain). I would also like to understand what to prepare now so that adding a co-founder, an early employee, or an outside investor later does not require unwinding decisions I made today. +> +> A secondary interest, if there is capacity: template agreements I can use if I bring on a contractor or collaborator, so that IP assignment and expectations are handled correctly from the start. + +## Why unmet legal needs / resource restraints + +> I am a full-time Ohio State undergraduate and the sole founder of a venture with zero revenue, funded entirely from my own pocket. I have spent a few hundred dollars total on the entire business. Retaining a startup attorney to review consumer-facing terms and a privacy policy, and to paper an operating agreement and IP assignment, would cost several thousand dollars — an order of magnitude more than everything I have spent on the venture combined, and well beyond what a student without outside funding can pay. +> +> The practical consequence is that these needs simply go unmet. I wrote my own Terms of Service and Privacy Policy because the alternative was shipping with nothing, and I filed my own LLC paperwork for the same reason. Both are live and in use by real users right now, and neither has ever been reviewed by a lawyer. That is exactly the gap I would like the EBLC's help closing — not because the venture is about to be sued, but because it is a consumer social app that is already in the market, and getting the trust-and-safety and privacy foundation right is more urgent before growth than after it. + +## Anything else we should know + +> Three things that may be relevant to how you scope the engagement: +> +> First, the app is already live and in the hands of real users, so any advice has an immediate practical application rather than being hypothetical. I can share the app, the current Terms of Service and Privacy Policy, and the App Store listing at any point. +> +> Second, there is a naming question I would appreciate a sanity check on. "Oval" is the name of the central green at Ohio State, and the app's design language uses scarlet. The app is built for and marketed to Ohio State students but is not affiliated with, endorsed by, or sponsored by the university. I would like to understand whether my current name and branding create any trademark or false-affiliation risk with respect to Ohio State's marks, and whether I need clearer disclaimers. +> +> Third, the product handles user-generated content and facilitates in-person meetings between adults, which I understand puts it in a category with its own risk profile. I have built moderation tooling — verification gating, blocking, reporting, no-show tracking, account deletion, and data export — and I would rather have a lawyer tell me what else that category demands than find out from an incident. + +## How did you hear about the EBLC + +**Ohio State contact** — or **Online search**, whichever is actually true for you. diff --git a/docs/GOOGLE_PLAY_ROADMAP.md b/docs/GOOGLE_PLAY_ROADMAP.md index 5168e9c..0daa77e 100644 --- a/docs/GOOGLE_PLAY_ROADMAP.md +++ b/docs/GOOGLE_PLAY_ROADMAP.md @@ -34,11 +34,11 @@ You want your name off the app anyway. A personal Play account displays your leg ## Phase 1 — Technical build (do in parallel with Phase 0) -Current repo state: `app.json` uses the Android package (`com.theovalapp.app`), includes adaptive icon and App Links intent filters, and the landing deployment proxies `/.well-known/assetlinks.json` to the backend; an Android notification channel is registered at HIGH importance; backend push (expo-server-sdk) is platform-agnostic. Remaining gaps: +Current repo state: `app.json` uses the Android package (`com.theovalapp.app`), includes adaptive and monochrome notification icons plus App Links intent filters, and the landing deployment proxies `/.well-known/assetlinks.json` to the backend. Android registers separate plan, message, club, discovery, and fallback channels so only time-sensitive plan updates use HIGH importance; backend push (expo-server-sdk) is platform-agnostic. Remaining gaps: - [x] **Package name check.** The release package is `com.theovalapp.app`; keep `app.json`, `ANDROID_PACKAGE`, Firebase, and the Play listing aligned to it. -- [ ] **FCM credentials.** Expo push on Android requires Firebase: create a Firebase project, add the Android app, download `google-services.json` (reference it via `android.googleServicesFile` in app.json), and upload the **FCM V1 service-account key** to EAS credentials (`eas credentials`). No backend changes needed — Expo's push service routes to FCM automatically. -- [ ] **First Android build:** `eas build --platform android --profile production` (produces .aab; EAS manages the upload keystore). +- [x] **FCM credentials.** Verified Aug. 9, 2026: `google-services.json` matches `com.theovalapp.app`, `app.json` references it, and the production EAS application identifier has an FCM V1 service-account key assigned. +- [x] **First Android build:** Production AAB builds have completed successfully; an installable notification-QA preview build was queued Aug. 9, 2026. - [ ] **App Links SHA-256.** After Play App Signing is set up (first upload), copy the app-signing cert SHA-256 from Play Console → set `ANDROID_SHA256_CERT_FINGERPRINT` on the backend and redeploy. Then verify both `https://api.theovalapp.com/.well-known/assetlinks.json` and `https://www.theovalapp.com/.well-known/assetlinks.json` contain the fingerprint. - [ ] **Target API level.** New apps must target API 35 now, **API 36 (Android 16) from Aug 31, 2026**. Expo SDK 56 should satisfy this — confirm `targetSdkVersion` in the build output; upgrade SDK if not. Don't cut it close to the Aug 31 boundary. - [ ] **EAS submit config.** Add an `android` block to `eas.json` submit profile: create a Google Cloud service account in Play Console (Setup → API access), download its JSON key, reference as `serviceAccountKeyPath`. First submission must be manual through the Console UI; `eas submit` works after that. diff --git a/docs/NOTIFICATIONS.md b/docs/NOTIFICATIONS.md new file mode 100644 index 0000000..be56959 --- /dev/null +++ b/docs/NOTIFICATIONS.md @@ -0,0 +1,64 @@ +# Notification system and copy + +Updated Aug. 9, 2026. The source of truth for generated copy is +`backend/src/lib/notificationCopy.ts`; this document is the product-facing +catalog and release checklist. + +## Rules + +- Every push must say what happened, name the relevant person/plan/club when + available, and deep-link to the exact next action. +- Never print zero-value vanity stats. Weekly planning stays silent unless at + least one joinable plan matches the member. +- Message previews collapse whitespace and stop at 110 characters. +- Transactional plan changes and reminders may interrupt. Conversations and + club updates use normal priority. Discovery and weekly suggestions are + low-priority and silent. +- Non-transactional pushes share a one-per-member-per-day budget. +- Every notification category shown below has an in-app preference. + +## Copy catalog + +| Event | Title | Body | Android channel | +| --- | --- | --- | --- | +| Someone joins | `{Maya} joined {Coffee Crawl}` | `{3} of {5} spots filled · {Saturday at 11:00 AM}` | Plans | +| Plan invite | `{Maya} invited you to {Coffee Crawl}` | `{Saturday at 11:00 AM} · {Short North} · {2 spots left}` | Plans | +| Plan time changed | `{Coffee Crawl} moved to {Saturday at 1:00 PM}` | Updated time and location | Plans | +| Plan location changed | `New location for {Coffee Crawl}` | Updated time and location | Plans | +| Plan canceled | `{Coffee Crawl} was canceled` | `{Saturday at 11:00 AM} is no longer happening.` | Plans | +| Pod message | `{Maya} · {Coffee Crawl}` | Message preview | Messages | +| Direct message | `{Maya}` | Message preview | Messages | +| Friend request | `{Maya} sent you a friend request` | `Open your Inbox to respond.` | Messages | +| One-hour reminder | `{Coffee Crawl} starts in 1 hour` | `{11:00 AM} at {Short North} · {4 people} going` | Plans | +| First-plan nudge | `Your first Oval plan is today` | Plan, time, location, and who is going | Plans | +| Post-plan rating | `How was {Coffee Crawl}?` | `Tap to rate it—it takes about 10 seconds.` | Plans | +| Waitlist opening | `A spot opened in {Coffee Crawl}` | `Join within 30 minutes before it goes to the next person.` | Plans | +| Twin plan | `Another {Coffee Crawl} opened` | Time, location, and `You have first look.` | Plans | +| Demand threshold | `{4} people are up for {Pickup Soccer}` | `Pick a time and start the plan.` | Discovery | +| Matching plan created | `{Pickup Soccer} is happening {tomorrow}` | Time, location, and `Join while spots are open.` | Discovery | +| Weekly planning, no attendance | `{3} plans match your interests` | `See what's forming around campus this week.` | Discovery | +| Weekly planning, attended | `You made it to {2} plans this week` | New people when nonzero, then matching plans for the coming week | Discovery | +| Club chat | `{Maya} · {Oval Builders} #{general}` | Message preview | Messages | +| Club role mention | `{Maya} mentioned {@officers}` | Club, channel, and message preview | Messages | +| Club meeting | `{Oval Builders} added {Weekly meeting}` | Date, time, and location | Clubs | +| Club announcement | `New from {Oval Builders}` | Announcement preview | Clubs | +| Club role change | `You're now an {officer} in {Oval Builders}` | Updated-permissions action | Clubs | +| Club removal | `You're no longer in {Oval Builders}` | `Your club membership was updated.` | Clubs | +| Attendance opens | `Check in to {Weekly meeting}` | `{Oval Builders} is meeting now.` | Plans | +| RSVP reminder | `Are you going to {Weekly meeting}?` | Club, date, and time | Clubs | +| Leader outreach | `Message from {Oval Builders}` | Leader-written preview | Clubs | + +## Release verification + +1. Deploy the backend so server-generated copy, recipient rules, preferences, + ticket handling, and new triggers are live. +2. Install a native Android build; an OTA update cannot change notification + icons or Android channel definitions. +3. On a physical Android device, allow notifications and verify token + registration, foreground/background/killed delivery, the white Oval status + icon, channel priority, and tap-through deep links. +4. Check an accepted Expo ticket's receipt after 15 minutes and confirm a + `DeviceNotRegistered` result clears the stored token. +5. Verify weekly planning on Sunday evening with zero history, singular counts, + prior co-attendees, no matching plans, and multiple matching plans. + diff --git a/docs/PLAY_DATA_SAFETY_2026-08.md b/docs/PLAY_DATA_SAFETY_2026-08.md new file mode 100644 index 0000000..d4d9567 --- /dev/null +++ b/docs/PLAY_DATA_SAFETY_2026-08.md @@ -0,0 +1,197 @@ +# Google Play Data Safety — answer sheet + +Derived from the code on 2026-08-06, not from memory. Every row cites where the +collection actually happens. Re-verify before submitting if the schema has moved. + +**You fill this in yourself** at Play Console → Policy → App content → Data safety. +It cannot be automated or uploaded from the repo. + +--- + +## Section 1: Data collection and security + +| Question | Answer | Basis | +|---|---|---| +| Does your app collect or share any of the required user data types? | **Yes** | See tables below | +| Is all user data encrypted in transit? | **Yes** | HTTPS only; Vercel enforces TLS. `helmet` set in `server.ts` | +| Do you provide a way for users to request data deletion? | **Yes** | `backend/src/lib/accountDeletion.ts` → `deleteUserAccount()`, exposed in-app | + +On the deletion question, Play asks for a URL. Point it at the in-app path +(Settings → Delete account) and the web fallback on theovalapp.com. + +--- + +## Section 2: Data types + +### Personal info + +| Data type | Collected | Shared | Optional? | Purpose | Where | +|---|---|---|---|---|---| +| Name | Yes | No | Required | App functionality, Account management | `User.name`, `firstName`, `lastName` | +| Email address | Yes | No | Required | App functionality, Account management | `User.email` (OSU verification gate) | +| User IDs | Yes | No | Required | App functionality | `User.id` (uuid) | +| Other info | Yes | No | Optional | App functionality | `bio`, `major`, `classYear`, `instagramHandle`, `interestTags`, `purpose`, `campusZones`, `clubInterests` | + +Note: `instagramHandle` is a third-party social identifier. It's optional and +user-entered — declare it under "Other info", flag as optional. + +### Photos and videos + +| Data type | Collected | Shared | Optional? | Purpose | Where | +|---|---|---|---|---|---| +| Photos | Yes | No | Optional | App functionality | `expo-image-picker` → Supabase Storage via `avatarStorage.ts`; `User.avatarUrl`, club avatars, `ClubChatScreen` uploads | + +### Messages + +| Data type | Collected | Shared | Optional? | Purpose | Where | +|---|---|---|---|---|---| +| Other in-app messages | Yes | No | Required | App functionality | `Message`, `DirectMessage`, `ClubMessage`, `ClubOfficerMessage`, `ClubAnnouncement` models | + +Play requires this for any app with chat. Do **not** mark messages as optional — +they're core functionality. + +### App activity + +| Data type | Collected | Shared | Optional? | Purpose | Where | +|---|---|---|---|---|---| +| App interactions | Yes | No | Required | Analytics, App functionality | `AnalyticsEvent` model; `trackEvent()` calls in `frontend/src/api.ts` | +| Other user-generated content | Yes | No | Required | App functionality | Pod titles, recaps, club content, reports | + +`AnalyticsEvent.userId` is a real FK to User, so these events are **not** +anonymous. Declare purpose as Analytics and confirm it's linked to identity. + +### App info and performance + +**Depends on whether the Sentry DSN is set for the build you ship. See +"Sentry status" below — as of 2026-08-06 it is being switched on.** + +### Sentry status + +Sentry was dormant until 2026-08-06 — the DSN was unset in `eas.json`, in the +local `.env`, and in EAS environment variables, so `monitoringEnabled` was false +and every call no-opped. + +It is now being enabled deliberately. **Once a build ships with +`EXPO_PUBLIC_SENTRY_DSN` set, answer:** + +| Data type | Collected | Shared | Ephemeral | Required? | Purpose | +|---|---|---|---|---|---| +| Crash logs | Yes | No | No | Required | Analytics | +| Diagnostics | Yes | No | No | Required | Analytics | + +- **Not "Shared"** — Sentry is a service provider processing on your behalf, + which Google exempts. +- **Diagnostics as well as Crash logs**, because `enableAutoSessionTracking: true` + and `tracesSampleRate: 0.1` send session health and performance traces, not + just crashes. +- **Required**, because there is no user-facing opt-out. Building a toggle would + let you answer "optional" instead. + +**No new personal-data rows.** `monitoring.ts` is configured anonymously: +`Sentry.setUser()` is never called, `sendDefaultPii: false` suppresses IP +capture, `beforeSend` clears `event.user` defensively, console breadcrumbs are +dropped (they could otherwise carry message text or profile fields), and query +strings are stripped from network breadcrumbs — without that last one, Sentry +would capture the `?lat=&lng=` params and undo the morgan redaction on the client +side. So Name, Email, User IDs and Messages gain no new destination. + +**If any of that changes, this declaration changes.** Attaching a user id or +email, or re-enabling console breadcrumbs, widens collection and must ship +together with an updated Data Safety form. + +### Device or other IDs + +| Data type | Collected | Shared | Optional? | Purpose | Where | +|---|---|---|---|---|---| +| Device or other IDs | Yes | No | Required | App functionality (push) | `User.pushToken` (Expo push token), `PushReceipt` model | + +--- + +## The location question — read this before answering + +This is the one genuinely ambiguous row, so decide deliberately. + +**What the code does:** `frontend/src/hooks/useLocationPermission.ts` requests +foreground permission. `HomeScreen.tsx:602` and `ExploreScreen.tsx:393` send +`lat`/`lng` as query params to sort pods by proximity. `ActivityPodsScreen.tsx` +calls `reverseGeocodeAsync` / `geocodeAsync`. + +**What is stored — read carefully, there are two different things here:** + +- **The user's own device position is never stored.** The `User` model has no + latitude/longitude column. The `lat`/`lng` sent from Home/Explore are used to + sort pods by proximity for that request and discarded. +- **`Pod.latitude` / `Pod.longitude` DO exist** (`schema.prisma`, `model Pod`), + but these are the *meetup venue's* coordinates, not anyone's device position. + They are set either by tapping a pin on the campus map + (`ActivityPodsScreen.handleMapPress`) or by geocoding a typed street address + (`Location.geocodeAsync`). Both are deliberate user choices about where to + meet, fenced to campus by `isCampusCoordinate`. + +The distinction matters for the content rating questionnaire: because pod +coordinates are a chosen venue rather than a live position, Oval does **not** +"share the user's current and precise physical location with other users." + +Google's data safety rules exempt data "processed ephemerally" (in memory, for +the current request only, never persisted). On a strict reading you could answer +**No** to location collection. + +**Previously `morgan` broke this.** Morgan's default `url` token logs the full +request URL including `?lat=...&lng=...`, parking precise coordinates in retained +Vercel logs. **Fixed 2026-08-06:** `server.ts` now overrides the `url` token to +rewrite `lat` and `lng` to `REDACTED` while keeping the rest of the query string. +Coordinates are no longer written to any log. + +With that in place, location collection **is** genuinely ephemeral — answer +"Yes, this collected data is processed ephemerally" on the Data Safety form. +Ephemeral data must still be disclosed but is not displayed on the public store +listing. + +**Answer it as Precise, not Approximate.** `useLocationPermission.ts` calls +`Location.getCurrentPositionAsync({})` with no accuracy option, so expo-location +uses its default `Accuracy.Balanced` — roughly 100 m, about 0.03 km². Google's +cutoff for "Approximate" is an area of 3 km² or greater, so this falls well +inside **Precise location**. + +Final answers for this row: Collected (not Shared) → **ephemeral: Yes** → +optional (the app works without the permission) → purpose: App functionality. + +--- + +## Third parties — none require "shared" + +"Shared" in Play's sense means transfer to a third party for *their* own use. +Service providers processing on your behalf don't count. + +| Vendor | Role | Counts as "shared"? | +|---|---|---| +| Supabase (Postgres + Storage) | Data processor | No | +| Vercel | Hosting | No | +| Resend | Transactional email | No | +| Expo push (`expo-server-sdk`) | Push delivery | No | + +So every row above can be answered **shared: No**. Worth stating plainly because +it's easy to over-declare here out of caution and end up with a scarier listing +than you've earned. + +--- + +## Security practices to check + +- [x] Data encrypted in transit +- [x] Users can request data deletion +- [x] Committed to Play Families Policy — **N/A**, app is 18+ +- [ ] Independent security review — leave unchecked, you haven't had one + +--- + +## Sequence + +1. Content rating questionnaire first — it asks about UGC and messaging, and the + answers should be consistent with this form. +2. Data safety form. +3. Target audience: **18+ only**. Do not select any age band under 18; that + triggers Families Policy requirements you don't meet and don't want. +4. App access: reviewers hit `requireVerifiedAuth`. Provide the App Store + reviewer bypass credentials under "All functionality is restricted" with + instructions, same as iOS. diff --git a/docs/google-play-listing-2026/oval-feature-graphic-1024x500.png b/docs/google-play-listing-2026/oval-feature-graphic-1024x500.png new file mode 100644 index 0000000..2ccbf0a Binary files /dev/null and b/docs/google-play-listing-2026/oval-feature-graphic-1024x500.png differ diff --git a/docs/google-play-listing-2026/tablet/10-inch/01-find-your-people.png b/docs/google-play-listing-2026/tablet/10-inch/01-find-your-people.png new file mode 100644 index 0000000..5a76adb Binary files /dev/null and b/docs/google-play-listing-2026/tablet/10-inch/01-find-your-people.png differ diff --git a/docs/google-play-listing-2026/tablet/10-inch/02-join-the-plan.png b/docs/google-play-listing-2026/tablet/10-inch/02-join-the-plan.png new file mode 100644 index 0000000..c17c78a Binary files /dev/null and b/docs/google-play-listing-2026/tablet/10-inch/02-join-the-plan.png differ diff --git a/docs/google-play-listing-2026/tablet/10-inch/03-plan-it-together.png b/docs/google-play-listing-2026/tablet/10-inch/03-plan-it-together.png new file mode 100644 index 0000000..247cc2d Binary files /dev/null and b/docs/google-play-listing-2026/tablet/10-inch/03-plan-it-together.png differ diff --git a/docs/google-play-listing-2026/tablet/10-inch/04-every-club-one-place.png b/docs/google-play-listing-2026/tablet/10-inch/04-every-club-one-place.png new file mode 100644 index 0000000..594f83b Binary files /dev/null and b/docs/google-play-listing-2026/tablet/10-inch/04-every-club-one-place.png differ diff --git a/docs/google-play-listing-2026/tablet/10-inch/05-stay-in-the-loop.png b/docs/google-play-listing-2026/tablet/10-inch/05-stay-in-the-loop.png new file mode 100644 index 0000000..94c8a5b Binary files /dev/null and b/docs/google-play-listing-2026/tablet/10-inch/05-stay-in-the-loop.png differ diff --git a/docs/google-play-listing-2026/tablet/10-inch/06-rsvp-in-a-tap.png b/docs/google-play-listing-2026/tablet/10-inch/06-rsvp-in-a-tap.png new file mode 100644 index 0000000..3f4e25e Binary files /dev/null and b/docs/google-play-listing-2026/tablet/10-inch/06-rsvp-in-a-tap.png differ diff --git a/docs/google-play-listing-2026/tablet/7-inch/01-find-your-people.png b/docs/google-play-listing-2026/tablet/7-inch/01-find-your-people.png new file mode 100644 index 0000000..89b188e Binary files /dev/null and b/docs/google-play-listing-2026/tablet/7-inch/01-find-your-people.png differ diff --git a/docs/google-play-listing-2026/tablet/7-inch/02-join-the-plan.png b/docs/google-play-listing-2026/tablet/7-inch/02-join-the-plan.png new file mode 100644 index 0000000..264ce35 Binary files /dev/null and b/docs/google-play-listing-2026/tablet/7-inch/02-join-the-plan.png differ diff --git a/docs/google-play-listing-2026/tablet/7-inch/03-plan-it-together.png b/docs/google-play-listing-2026/tablet/7-inch/03-plan-it-together.png new file mode 100644 index 0000000..7527e7c Binary files /dev/null and b/docs/google-play-listing-2026/tablet/7-inch/03-plan-it-together.png differ diff --git a/docs/google-play-listing-2026/tablet/7-inch/04-every-club-one-place.png b/docs/google-play-listing-2026/tablet/7-inch/04-every-club-one-place.png new file mode 100644 index 0000000..ab40049 Binary files /dev/null and b/docs/google-play-listing-2026/tablet/7-inch/04-every-club-one-place.png differ diff --git a/docs/google-play-listing-2026/tablet/7-inch/05-stay-in-the-loop.png b/docs/google-play-listing-2026/tablet/7-inch/05-stay-in-the-loop.png new file mode 100644 index 0000000..f4965aa Binary files /dev/null and b/docs/google-play-listing-2026/tablet/7-inch/05-stay-in-the-loop.png differ diff --git a/docs/google-play-listing-2026/tablet/7-inch/06-rsvp-in-a-tap.png b/docs/google-play-listing-2026/tablet/7-inch/06-rsvp-in-a-tap.png new file mode 100644 index 0000000..2897c69 Binary files /dev/null and b/docs/google-play-listing-2026/tablet/7-inch/06-rsvp-in-a-tap.png differ diff --git a/docs/google-play-listing-2026/tablet/oval-tablet-screenshots-10-inch.zip b/docs/google-play-listing-2026/tablet/oval-tablet-screenshots-10-inch.zip new file mode 100644 index 0000000..49cd326 Binary files /dev/null and b/docs/google-play-listing-2026/tablet/oval-tablet-screenshots-10-inch.zip differ diff --git a/docs/google-play-listing-2026/tablet/oval-tablet-screenshots-7-inch.zip b/docs/google-play-listing-2026/tablet/oval-tablet-screenshots-7-inch.zip new file mode 100644 index 0000000..d8470b5 Binary files /dev/null and b/docs/google-play-listing-2026/tablet/oval-tablet-screenshots-7-inch.zip differ diff --git a/frontend/.easignore b/frontend/.easignore new file mode 100644 index 0000000..ddbac88 --- /dev/null +++ b/frontend/.easignore @@ -0,0 +1,39 @@ +# Dependencies are installed by EAS from package-lock.json. +node_modules/ + +# Local Expo/build output. +.expo/ +dist/ +web-build/ +ios/ +android/ +*.tsbuildinfo +.metro-health-check* + +# Editor and OS metadata. +.idea/ +.vscode/ +.DS_Store + +# Tests and local-only developer material are not required in store bundles. +**/*.test.ts +**/*.test.tsx +__mocks__/ +coverage/ +DESIGN_AUDIT.md +CLAUDE.md + +# Local secrets and logs. Public build values come from eas.json / EAS env. +.env +.env*.local +*.log +npm-debug.* +yarn-debug.* +yarn-error.* + +# Signing files are supplied by EAS credentials. +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision diff --git a/frontend/.env.example b/frontend/.env.example index aef0114..887df83 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -6,6 +6,18 @@ EXPO_PUBLIC_API_URL=http://localhost:3000 EXPO_PUBLIC_SUPABASE_URL= EXPO_PUBLIC_SUPABASE_ANON_KEY= -# Optional crash reporting. Set the DSN as an EAS environment secret for release builds. +# Crash reporting (Sentry). Leaving the DSN empty disables Sentry entirely — +# src/lib/monitoring.ts no-ops, and nothing is sent off device. Leave it empty +# locally unless you are specifically testing crash reporting. EXPO_PUBLIC_SENTRY_DSN= EXPO_PUBLIC_ENV=development + +# Source map upload for readable stack traces. Build-time only — never bundled +# into the app. The org and project slugs live in app.json under the +# @sentry/react-native/expo plugin config, so the only value needed here is the +# auth token, set as an EAS secret: +# eas env:create --name SENTRY_AUTH_TOKEN --value --environment production --environment preview --visibility secret +# If SENTRY_AUTH_TOKEN is missing the build still succeeds, but stack traces +# stay minified. See docs/PLAY_DATA_SAFETY_2026-08.md before changing what +# Sentry collects — it affects the Play Data Safety declaration. +SENTRY_AUTH_TOKEN= diff --git a/frontend/.idea/.gitignore b/frontend/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/frontend/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/frontend/.idea/caches/deviceStreaming.xml b/frontend/.idea/caches/deviceStreaming.xml new file mode 100644 index 0000000..e112b74 --- /dev/null +++ b/frontend/.idea/caches/deviceStreaming.xml @@ -0,0 +1,2241 @@ + + + + + + \ No newline at end of file diff --git a/frontend/.idea/deviceManager.xml b/frontend/.idea/deviceManager.xml new file mode 100644 index 0000000..91f9558 --- /dev/null +++ b/frontend/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/frontend/.idea/frontend.iml b/frontend/.idea/frontend.iml new file mode 100644 index 0000000..d6ebd48 --- /dev/null +++ b/frontend/.idea/frontend.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/frontend/.idea/misc.xml b/frontend/.idea/misc.xml new file mode 100644 index 0000000..4b151ab --- /dev/null +++ b/frontend/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/.idea/modules.xml b/frontend/.idea/modules.xml new file mode 100644 index 0000000..f3d93d7 --- /dev/null +++ b/frontend/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/frontend/.idea/vcs.xml b/frontend/.idea/vcs.xml new file mode 100644 index 0000000..6c0b863 --- /dev/null +++ b/frontend/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/App.tsx b/frontend/App.tsx index e1469c7..2ef513c 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -24,6 +24,7 @@ import Animated, { import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context'; import { AuthProvider, useAuth } from './src/context/AuthContext'; +import { SharedStateInvalidationProvider } from './src/context/SharedStateInvalidationContext'; import AuthScreen from './src/screens/AuthScreen'; import VerifyEmailScreen from './src/screens/VerifyEmailScreen'; import HomeScreen from './src/screens/HomeScreen'; @@ -95,6 +96,13 @@ import { AppBackdrop, CountBubble, SkeletonBlock, SkeletonCard } from './src/com import { CURRENT_TERMS_VERSION } from './src/constants/legal'; import { REALTIME_INBOX_EVENTS, useRealtimeChannel } from './src/hooks/useRealtimeChannel'; import { captureReferralFromUrl } from './src/lib/referrals'; +import { trackEvent } from './src/api'; + +// NOTE: Sentry is initialised once, by initMonitoring() above. Do not add a +// second Sentry.init() here — @sentry/wizard tries to inject one with a +// hardcoded DSN and sendDefaultPii: true, which would defeat the DSN gating and +// the anonymous configuration in src/lib/monitoring.ts. If you rerun the wizard, +// delete what it adds to this file. SplashScreen.preventAutoHideAsync().catch(() => {}); if (Platform.OS !== 'web') { @@ -109,13 +117,42 @@ if (Platform.OS !== 'web') { }); } if (Platform.OS === 'android') { - // Expo pushes land on the 'default' channel; without registering it with - // high importance, Android shows no heads-up banner (silent tray only). - void Notifications.setNotificationChannelAsync('default', { - name: 'Default', - importance: Notifications.AndroidImportance.HIGH, - sound: 'default', - }).catch(() => {}); + // Keep urgency honest: reminders and plan changes may interrupt, ordinary + // conversations use normal priority, and discovery never makes noise. + void Promise.all([ + Notifications.setNotificationChannelAsync('plans', { + name: 'Plans and reminders', + description: 'Time-sensitive plan changes, reminders, invitations, and waitlist openings.', + importance: Notifications.AndroidImportance.HIGH, + sound: 'default', + vibrationPattern: [0, 200, 120, 200], + }), + Notifications.setNotificationChannelAsync('messages', { + name: 'Messages and connections', + description: 'Direct, pod, and club messages plus connection requests.', + importance: Notifications.AndroidImportance.DEFAULT, + sound: 'default', + }), + Notifications.setNotificationChannelAsync('clubs', { + name: 'Club updates', + description: 'Meetings, announcements, roles, and leader updates.', + importance: Notifications.AndroidImportance.DEFAULT, + sound: 'default', + }), + Notifications.setNotificationChannelAsync('discovery', { + name: 'Weekly planning and recommendations', + description: 'Low-priority planning suggestions and activity matches.', + importance: Notifications.AndroidImportance.LOW, + sound: null, + vibrationPattern: null, + }), + // Backward-compatible fallback for older or malformed payloads. + Notifications.setNotificationChannelAsync('default', { + name: 'Other notifications', + importance: Notifications.AndroidImportance.DEFAULT, + sound: 'default', + }), + ]).catch(() => {}); } export type MainTabParamList = { @@ -181,7 +218,12 @@ function urlFromNotificationResponse( const id = response.notification.request.identifier; if (id && lastHandledNotificationId === id) return null; if (id) lastHandledNotificationId = id; - const url = response.notification.request.content.data?.url; + const data = response.notification.request.content.data; + const url = data?.url; + void trackEvent('notification.opened', { + type: typeof data?.type === 'string' ? data.type : 'unknown', + notificationId: id || undefined, + }); return typeof url === 'string' && url.length > 0 ? url : null; } @@ -939,7 +981,9 @@ function App() { - + + + diff --git a/frontend/app.json b/frontend/app.json index e78de8d..50bcad2 100644 --- a/frontend/app.json +++ b/frontend/app.json @@ -8,12 +8,20 @@ "userInterfaceStyle": "automatic", "scheme": "oval", "plugins": [ - "@sentry/react-native/expo", + [ + "@sentry/react-native/expo", + { + "url": "https://sentry.io/", + "organization": "oval-technologies-llc", + "project": "react-native" + } + ], [ "expo-notifications", { - "icon": "./assets/brand/oval-open-1024-transparent.png", + "icon": "./assets/brand/oval-notification-96.png", "color": "#EF5A63", + "defaultChannel": "messages", "sounds": [] } ], diff --git a/frontend/assets/brand/oval-notification-96.png b/frontend/assets/brand/oval-notification-96.png new file mode 100644 index 0000000..0bc9722 Binary files /dev/null and b/frontend/assets/brand/oval-notification-96.png differ diff --git a/frontend/assets/brand/oval-notification.svg b/frontend/assets/brand/oval-notification.svg new file mode 100644 index 0000000..192ed01 --- /dev/null +++ b/frontend/assets/brand/oval-notification.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/eas.json b/frontend/eas.json index fc3f9d7..3b965dd 100644 --- a/frontend/eas.json +++ b/frontend/eas.json @@ -15,7 +15,6 @@ "env": { "EXPO_PUBLIC_API_URL": "https://api.theovalapp.com", "EXPO_PUBLIC_ENV": "preview", - "SENTRY_DISABLE_AUTO_UPLOAD": "true", "EXPO_PUBLIC_SUPABASE_URL": "https://vwxvtmyqjmihncufhkwj.supabase.co", "EXPO_PUBLIC_SUPABASE_ANON_KEY": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InZ3eHZ0bXlxam1paG5jdWZoa3dqIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Njg2ODcyNzIsImV4cCI6MjA4NDI2MzI3Mn0.LKuIKs-MNY_OW0Nv0i9Ldivp2jki8U6pNS5HFFLRhJg" } @@ -26,7 +25,6 @@ "env": { "EXPO_PUBLIC_API_URL": "https://api.theovalapp.com", "EXPO_PUBLIC_ENV": "production", - "SENTRY_DISABLE_AUTO_UPLOAD": "true", "EXPO_PUBLIC_SUPABASE_URL": "https://vwxvtmyqjmihncufhkwj.supabase.co", "EXPO_PUBLIC_SUPABASE_ANON_KEY": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InZ3eHZ0bXlxam1paG5jdWZoa3dqIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Njg2ODcyNzIsImV4cCI6MjA4NDI2MzI3Mn0.LKuIKs-MNY_OW0Nv0i9Ldivp2jki8U6pNS5HFFLRhJg" } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1ea090c..b3e530a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,19 +16,19 @@ "@react-navigation/native-stack": "^7.13.0", "@sentry/react-native": "~7.11.0", "@supabase/supabase-js": "^2.103.0", - "expo": "~56.0.18", + "expo": "~56.0.19", "expo-blur": "~56.0.4", "expo-clipboard": "~56.0.4", - "expo-file-system": "~56.0.8", + "expo-file-system": "~56.0.9", "expo-font": "~56.0.7", "expo-haptics": "~56.0.3", - "expo-image-manipulator": "~56.0.23", - "expo-image-picker": "~56.0.22", + "expo-image-manipulator": "~56.0.24", + "expo-image-picker": "~56.0.23", "expo-linear-gradient": "~56.0.4", - "expo-location": "~56.0.22", - "expo-notifications": "~56.0.22", + "expo-location": "~56.0.23", + "expo-notifications": "~56.0.23", "expo-secure-store": "~56.0.4", - "expo-sharing": "~56.0.23", + "expo-sharing": "~56.0.24", "expo-splash-screen": "~56.0.14", "expo-status-bar": "~56.0.4", "react": "19.2.3", @@ -85,6 +85,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -1150,7 +1151,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, @@ -1166,7 +1166,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, @@ -1198,7 +1197,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.7.tgz", "integrity": "sha512-rNNFV0DBAJp988xW2DOntfDoYn1eR8GGF5AT5vYc+rjyfaQkM242c9tZUHHPe7KYaiJizXPWhQTzzdbXySyhBw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, @@ -2425,7 +2423,6 @@ "resolved": "https://registry.npmjs.org/@react-native/babel-preset/-/babel-preset-0.85.3.tgz", "integrity": "sha512-fD7fxEhkJB/aF57tWoXjaAWpklfrExYZS3k6aXPP3BQ77DZY7gvf/b7dbirwjID6NVnP1JDRJyTuPBGr0K/vlw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "^7.25.2", "@babel/plugin-proposal-export-default-from": "^7.24.7", @@ -2580,6 +2577,7 @@ "integrity": "sha512-ALPSrM0q2fU+5AXcOXzDKx7rxVKPMvygAZfsTWLdrGRVWIqf/HEfM0R8euQqIKUqmEuQ1TxMWN+px3h6gc4vow==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/js-polyfills": "0.85.3", @@ -2608,7 +2606,6 @@ "resolved": "https://registry.npmjs.org/@react-native/metro-babel-transformer/-/metro-babel-transformer-0.85.3.tgz", "integrity": "sha512-omuKq+r7jM4XvCMIlNMPP7Up3SyB8o5EAdZtF7YXniKyq7UOMBqhYHFqgsdOXr0lT+3ADf7VCJG3sb82jlBrrQ==", "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "^7.25.2", "@react-native/babel-preset": "0.85.3", @@ -2727,6 +2724,7 @@ "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.28.tgz", "integrity": "sha512-d1QDn+KNHfHGt3UIwOZvupvdsDdiHYZBEj7+wL2yDVo3tMezamYy60H9s3EnNVE1Ae1ty0trc7F2OKqo/RmsdQ==", "license": "MIT", + "peer": true, "dependencies": { "@react-navigation/core": "^7.14.0", "escape-string-regexp": "^4.0.0", @@ -3599,9 +3597,9 @@ } }, "node_modules/agent-cli-detector": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/agent-cli-detector/-/agent-cli-detector-0.1.4.tgz", - "integrity": "sha512-qPgevFvpaQoBaRJVKzr8R7h1WPvV3DtbgRIQlne4le66KBzXx5hNBwo/+NTw67LgkKBlhCzksrdautpUdlls0Q==", + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/agent-cli-detector/-/agent-cli-detector-0.1.5.tgz", + "integrity": "sha512-6xvLw0EGPuxoYGZeqyMV4AK+WoZ31jsqb7a5pln1BTf6oDFsrgvfCJT7E7y9oAqifJZhJ3fZ0J36H7o6JzrB0Q==", "license": "MIT", "bin": { "agent-cli-detector": "dist/cli.js" @@ -3874,9 +3872,9 @@ } }, "node_modules/babel-preset-expo": { - "version": "56.0.18", - "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-56.0.18.tgz", - "integrity": "sha512-QHEa3AasSFjnA08ciQkKKdVKWPRc2ye6UvxwCweYr3rKATBwTUVOuA0mzRD2PFVdB16R9yqgHROpiqL+K4A0Ww==", + "version": "56.0.19", + "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-56.0.19.tgz", + "integrity": "sha512-js8jq/7x2jclTbfmc3Yp24B8r+KEAE7kmla2vpPVPiCfidi77lJMUAAZ3KBYXXOFaoWF0esAlFuLIuMIMHcmDg==", "license": "MIT", "dependencies": { "@babel/generator": "^7.20.5", @@ -3925,7 +3923,7 @@ "peerDependencies": { "@babel/runtime": "^7.20.0", "expo": "*", - "expo-widgets": "^56.0.24", + "expo-widgets": "^56.0.26", "react-refresh": ">=0.14.0 <1.0.0" }, "peerDependenciesMeta": { @@ -4074,6 +4072,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5113,13 +5112,14 @@ } }, "node_modules/expo": { - "version": "56.0.18", - "resolved": "https://registry.npmjs.org/expo/-/expo-56.0.18.tgz", - "integrity": "sha512-xEYiFlkeOzEKEEJlE6qFYtSo+HOwcIDcwZodRqLQRlhPOHU8bYzutyednlcKbytrS6NMEFpwsBwt5LiN1ZLEjA==", + "version": "56.0.19", + "resolved": "https://registry.npmjs.org/expo/-/expo-56.0.19.tgz", + "integrity": "sha512-IqsNM8oATJpgAo+sLM90Ct533J+T1grCh8PpvcsrvEVw6vF42VIHKO0DGEgarAio8LOWm7T9eUiJGgh0ouAe3g==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.20.0", - "@expo/cli": "^56.1.22", + "@expo/cli": "^56.1.23", "@expo/config": "~56.0.13", "@expo/config-plugins": "~56.0.14", "@expo/devtools": "~56.0.2", @@ -5130,14 +5130,14 @@ "@expo/metro": "~56.0.0", "@expo/metro-config": "~56.0.18", "@ungap/structured-clone": "^1.3.0", - "babel-preset-expo": "~56.0.18", - "expo-asset": "~56.0.21", - "expo-constants": "~56.0.22", - "expo-file-system": "~56.0.8", + "babel-preset-expo": "~56.0.19", + "expo-asset": "~56.0.22", + "expo-constants": "~56.0.23", + "expo-file-system": "~56.0.9", "expo-font": "~56.0.7", "expo-keep-awake": "~56.0.3", "expo-modules-autolinking": "~56.0.21", - "expo-modules-core": "~56.0.22", + "expo-modules-core": "~56.0.23", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-minimum": "^0.1.2" @@ -5184,13 +5184,13 @@ } }, "node_modules/expo-asset": { - "version": "56.0.21", - "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-56.0.21.tgz", - "integrity": "sha512-0p5JByDPy6MxB1S2TBoz38r6I8IYx6z5uD/e00nc0sc9f8xRpcZPCJALbOsGW8PjOwXNfTyEE9KsC6xTLjC2ZA==", + "version": "56.0.22", + "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-56.0.22.tgz", + "integrity": "sha512-KIlIskLTD1a/rtoEIiJv0IeRAnnxRavD2oLyHI0aCEs4zl0yNHM2xLZHh05PWvAHJbBlbopfOWW1g8IbJp6ttg==", "license": "MIT", "dependencies": { "@expo/image-utils": "^0.10.4", - "expo-constants": "~56.0.22" + "expo-constants": "~56.0.23" }, "peerDependencies": { "expo": "*", @@ -5221,10 +5221,11 @@ } }, "node_modules/expo-constants": { - "version": "56.0.22", - "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-56.0.22.tgz", - "integrity": "sha512-XNRssTBSGDR3gsaYhhp513WdpPsLi89wgfYT0wLsCtMY8vXAgXR4jNlaYGXFCnvNVbx9gEDSBHOSntqRK1i8Lw==", + "version": "56.0.23", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-56.0.23.tgz", + "integrity": "sha512-rlHmhwvKqljCNdqs4xdxcHzk22rbnVv05lyQqQtYXFkzreFErsQYVDvDCiw3LG6vjQRRTPTEdSgtyNgs8y9cBA==", "license": "MIT", + "peer": true, "dependencies": { "@expo/env": "~2.3.1" }, @@ -5248,9 +5249,9 @@ } }, "node_modules/expo-file-system": { - "version": "56.0.8", - "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-56.0.8.tgz", - "integrity": "sha512-NrH41/8snGIBSbYicwVLB4txPdgCATd7ZYhMAGS3YJZ9GbnduhlAoV4/YCbGayjrbpE9bJb/6wegPL/zmvRMnQ==", + "version": "56.0.9", + "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-56.0.9.tgz", + "integrity": "sha512-5M1a7+ffiJGhn/IN5kRcM8uG8V0REMciwEUPUvKykNvjFHyfKfV4USeWhuIjLvTWvDtsPiqdk56uvhFXrhR7nQ==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -5262,6 +5263,7 @@ "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-56.0.7.tgz", "integrity": "sha512-hpU/vRwPzsby9lPGkA4blDqLIIXYzoWnCZHr6PxvcWbY/uPObAiyhh6q+e0WYsB65SthK+PLH95jEnVag7fwEg==", "license": "MIT", + "peer": true, "dependencies": { "fontfaceobserver": "^2.1.0" }, @@ -5290,9 +5292,9 @@ } }, "node_modules/expo-image-manipulator": { - "version": "56.0.23", - "resolved": "https://registry.npmjs.org/expo-image-manipulator/-/expo-image-manipulator-56.0.23.tgz", - "integrity": "sha512-68bcPNlIAaxPPSTkWeb//bqO9Y+qkG8DHmZTJibjmiN8dfZ2PiwSaW35AZEl4OR+CMMWybUvujV8Y352s8bkPA==", + "version": "56.0.24", + "resolved": "https://registry.npmjs.org/expo-image-manipulator/-/expo-image-manipulator-56.0.24.tgz", + "integrity": "sha512-GDEE0I3ZKgOxWBrE0IT/Lheb5M1wXydprHOt/mPABjq+1gEaKOjm0hUHxjKbL3g6WmRyzfJx/Szv51vJ81FAcg==", "license": "MIT", "dependencies": { "expo-image-loader": "~56.0.3" @@ -5302,9 +5304,9 @@ } }, "node_modules/expo-image-picker": { - "version": "56.0.22", - "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-56.0.22.tgz", - "integrity": "sha512-4QrLk07a+WqCSZkmk2tbwFa57HgPYZisM2/n+OQ5kS3ty923x9dfxFXSdh17zF1lRCHhpsXj8H24+0IjcgVqIQ==", + "version": "56.0.23", + "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-56.0.23.tgz", + "integrity": "sha512-OvY1AhxBtukKqYtgL9nKtKRUFQfJqwsfZzg4rVDJQRD5rHJkSiW2HalCZB0ov7GFCmrBD5qe+tGgij2vweS2cA==", "license": "MIT", "dependencies": { "expo-image-loader": "~56.0.3" @@ -5325,9 +5327,9 @@ } }, "node_modules/expo-location": { - "version": "56.0.22", - "resolved": "https://registry.npmjs.org/expo-location/-/expo-location-56.0.22.tgz", - "integrity": "sha512-gX4WlukHV3s7N7qyhHbkZ88DsbJ2sOS04CHDaxAHGN/1b0hcJv0SqpJ4z+DyFZ4AT223Vd8RhYejiPkU1N+3kA==", + "version": "56.0.23", + "resolved": "https://registry.npmjs.org/expo-location/-/expo-location-56.0.23.tgz", + "integrity": "sha512-MrmqbuNrefReNZ+St7YPOup9YeSSTefNmI1sGFUDQWUZ4u/xNrKzEabFMn5psIjpaWrfk/gP96gGcUqouvoR+g==", "license": "MIT", "dependencies": { "@expo/image-utils": "^0.10.4" @@ -5352,9 +5354,9 @@ } }, "node_modules/expo-modules-core": { - "version": "56.0.22", - "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-56.0.22.tgz", - "integrity": "sha512-VmkVNpbjC+gTny1pvxyxu90LafGpMdhUB5TRSpe+So4ICrZCwcWBADjWc0JVBHKdFCMOaWBMskuIHI/RcNhEtQ==", + "version": "56.0.23", + "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-56.0.23.tgz", + "integrity": "sha512-5XICEk65GXP37g87GSxivHydyL8mizvVMUfF3YFtt6zQtniuPqkWfEFp35LqnaDCxC0OLAAtMPW9P/dRWBe78A==", "license": "MIT", "dependencies": { "@expo/expo-modules-macros-plugin": "0.2.2", @@ -5382,16 +5384,16 @@ } }, "node_modules/expo-notifications": { - "version": "56.0.22", - "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-56.0.22.tgz", - "integrity": "sha512-aT8UFfSJpjQKZDV5SGyxFZ+ocS0nFmEqCX+RcRGRi7RM8xhGGDwF8/xIMKOwp6OztmLnUmFf/nu/bbgQ/IfMEw==", + "version": "56.0.23", + "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-56.0.23.tgz", + "integrity": "sha512-6Rr6eBdcR+bvgfel2v30oN+8PZ8wTPyKXWI7bpgBdQSv7d3OJVrUMa4raQxtc7RdGvnDTf8r9+YzgxE/X4g3YQ==", "license": "MIT", "dependencies": { "@expo/image-utils": "^0.10.4", "abort-controller": "^3.0.0", "badgin": "^1.1.5", "expo-application": "~56.0.3", - "expo-constants": "~56.0.22" + "expo-constants": "~56.0.23" }, "peerDependencies": { "expo": "*", @@ -5413,14 +5415,15 @@ "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-56.0.5.tgz", "integrity": "sha512-SmM2p2g3Jrktpiazcst+OxhjSzOHXKAY4BPURHYHXvApzzoybMmrNF4IEZ8DKZ145BhSe4ydAmlEFCRTsdtgUQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=20.16.0" } }, "node_modules/expo-sharing": { - "version": "56.0.23", - "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-56.0.23.tgz", - "integrity": "sha512-O/f6+ttCuwwTDnzM+ZqeVeNOJJ3lY1qvhoMfYeYAgEFi36qms5j+uuUISm520wmrG+1AwT7Is5tdSRk4zf+0AA==", + "version": "56.0.24", + "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-56.0.24.tgz", + "integrity": "sha512-+pk1u/TdrGKGdLDtRyMiZG5DGrNhbjjHRLA5vU6gj+bQmLtpBpfrjenHLWDLBEvhFHF02FV37lqBRVpF/hFIiw==", "license": "MIT", "dependencies": { "@expo/config-plugins": "^56.0.14", @@ -5459,9 +5462,9 @@ } }, "node_modules/expo/node_modules/@expo/cli": { - "version": "56.1.22", - "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-56.1.22.tgz", - "integrity": "sha512-G9IOhKU62TexMJGeZkDmyAYLsvbYT40RvIG7AR4y2ha8OEmhbbHNLNQ0mI0xK4QVf4MXiC0CPHLhMMh/ak8XQA==", + "version": "56.1.23", + "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-56.1.23.tgz", + "integrity": "sha512-a6FU2K0rIGaXwRsX/wEllXIryIkM0TynL146UDaQEQHbGnMya7RLchSa1WKFrN0V0NTCrEEo0YBUDA+vlYle1Q==", "license": "MIT", "dependencies": { "@expo/code-signing-certificates": "^0.0.6", @@ -5635,10 +5638,11 @@ } }, "node_modules/expo/node_modules/ws": { - "version": "8.21.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", - "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -6533,6 +6537,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -7596,9 +7601,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", - "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", + "version": "3.15.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz", + "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==", "devOptional": true, "license": "MIT", "dependencies": { @@ -8704,15 +8709,15 @@ "license": "MIT" }, "node_modules/multitars": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/multitars/-/multitars-1.0.0.tgz", - "integrity": "sha512-H/J4fMLedtudftaYMOg7ajzLYgT3/rwbWVJbqr/iUgB8DQztn38ys5HOqI1CzSxx8QhXXwOOnnBvd4v3jG5+Mg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/multitars/-/multitars-1.0.1.tgz", + "integrity": "sha512-Do9rHaSDfQ2Fk5Dpg2RjQ4M48Ol7rSq1gvKn/dXJYnhKEm8RRjjUvyiGDDEhJqb0hJPGjRaTB1kx6beqKO/i6Q==", "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", @@ -9273,9 +9278,9 @@ } }, "node_modules/postcss": { - "version": "8.5.25", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", - "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "funding": [ { "type": "opencollective", @@ -9292,7 +9297,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.16", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -9466,6 +9471,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -9485,6 +9491,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -9515,6 +9522,7 @@ "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.85.3.tgz", "integrity": "sha512-HN/fGC+3nZVcDNcw7gfbM/DuqZAvI9Mz+/SxuhODaua4JY0BPzhfTzWXRyTR4mRgMHmShTPpH2PYMTxvZrsdZA==", "license": "MIT", + "peer": true, "dependencies": { "@react-native/assets-registry": "0.85.3", "@react-native/codegen": "0.85.3", @@ -9637,6 +9645,7 @@ "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.7.0.tgz", "integrity": "sha512-/9/MtQz8ODphjsLdZ+GZAIcC/RtoqW9EeShf7Uvnfgm/pzYrJ75y3PV/J1wuAV1T5Dye5ygq4EAW20RoBq0ABQ==", "license": "MIT", + "peer": true, "peerDependencies": { "react": "*", "react-native": "*" @@ -9647,6 +9656,7 @@ "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.26.2.tgz", "integrity": "sha512-2XnWsZToKj76trGtEZzx5ELD/qOICFEprEeUntImmitQFVUkea27fiWdUSITArI356Y1qynpXZINW+Unbhky/A==", "license": "MIT", + "peer": true, "dependencies": { "react-freeze": "^1.0.0", "warn-once": "^0.1.0" @@ -9661,6 +9671,7 @@ "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz", "integrity": "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.6", "@react-native/normalize-colors": "^0.74.1", @@ -9693,6 +9704,7 @@ "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.8.3.tgz", "integrity": "sha512-oCBJROyLU7yG/1R8s0INMflygTH71bx+5XcYkH0CM938TlhSoVbiunE1WVW5FZa51vwYqfLie/IXMX2s1Kh3eg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/plugin-transform-arrow-functions": "^7.27.1", "@babel/plugin-transform-class-properties": "^7.27.1", @@ -9750,6 +9762,7 @@ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -9760,6 +9773,7 @@ "integrity": "sha512-TMR1LnSFiWZMJkCgNf5ATSvAheTT2NvKIwiVwdBPHxjBI7n/JbWd4gaZ16DVd9foAXdvDz+sB5yxZTwMjPRxpw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "react-is": "^19.2.3", "scheduler": "^0.27.0" @@ -10643,6 +10657,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -10743,6 +10758,7 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/frontend/package.json b/frontend/package.json index b76a7b8..e5a6e45 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -27,19 +27,19 @@ "@react-navigation/native-stack": "^7.13.0", "@sentry/react-native": "~7.11.0", "@supabase/supabase-js": "^2.103.0", - "expo": "~56.0.18", + "expo": "~56.0.19", "expo-blur": "~56.0.4", "expo-clipboard": "~56.0.4", - "expo-file-system": "~56.0.8", + "expo-file-system": "~56.0.9", "expo-font": "~56.0.7", "expo-haptics": "~56.0.3", - "expo-image-manipulator": "~56.0.23", - "expo-image-picker": "~56.0.22", + "expo-image-manipulator": "~56.0.24", + "expo-image-picker": "~56.0.23", "expo-linear-gradient": "~56.0.4", - "expo-location": "~56.0.22", - "expo-notifications": "~56.0.22", + "expo-location": "~56.0.23", + "expo-notifications": "~56.0.23", "expo-secure-store": "~56.0.4", - "expo-sharing": "~56.0.23", + "expo-sharing": "~56.0.24", "expo-splash-screen": "~56.0.14", "expo-status-bar": "~56.0.4", "react": "19.2.3", @@ -68,6 +68,7 @@ "@react-native/metro-babel-transformer": "0.85.3", "@react-native/metro-config": "0.85.3", "brace-expansion": "^5.0.9", + "js-yaml": "^3.15.1", "shell-quote": "^1.8.4", "uuid": "^11.1.1" }, diff --git a/frontend/src/__tests__/api.test.ts b/frontend/src/__tests__/api.test.ts index a7fa9f5..30bd781 100644 --- a/frontend/src/__tests__/api.test.ts +++ b/frontend/src/__tests__/api.test.ts @@ -1,6 +1,8 @@ import { getApiErrorMessage, + getActivities, getMessages, + getThreadMessages, getToken, getUserProfileShareUrl, setOnUnauthorized, @@ -149,3 +151,95 @@ describe('request — 401 handling', () => { ); }); }); + +describe('live message reads', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('bypasses app and native caches when realtime refetches a DM thread', async () => { + global.fetch = jest + .fn() + .mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => + JSON.stringify({ + messages: [], + otherUser: { id: 'user-2', name: 'Friend' }, + typingUserIds: [], + otherLastReadAt: null, + }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => + JSON.stringify({ + messages: [{ id: 'message-1', createdAt: '2026-08-08T12:00:00.000Z' }], + otherUser: { id: 'user-2', name: 'Friend' }, + typingUserIds: [], + otherLastReadAt: null, + }), + }); + + await getThreadMessages('thread-1', { limit: 50 }); + const refreshed = await getThreadMessages('thread-1', { limit: 50 }); + + expect(global.fetch).toHaveBeenCalledTimes(2); + expect(global.fetch).toHaveBeenNthCalledWith( + 2, + expect.stringContaining('/messages/threads/thread-1?limit=50'), + expect.objectContaining({ cache: 'no-store' }), + ); + expect(refreshed.messages).toHaveLength(1); + }); + + it('also keeps pod message polling uncached', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify({ messages: [], typingUserIds: [] }), + }); + + await getMessages('pod-1', { limit: 50 }); + await getMessages('pod-1', { limit: 50 }); + + expect(global.fetch).toHaveBeenCalledTimes(2); + expect(global.fetch).toHaveBeenLastCalledWith( + expect.stringContaining('/pods/pod-1/messages?limit=50'), + expect.objectContaining({ cache: 'no-store' }), + ); + }); +}); + +describe('shared app reads', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('does not reuse stale data for ordinary authenticated screens', async () => { + global.fetch = jest + .fn() + .mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => JSON.stringify([{ id: 'activity-1', demandCount: 1 }]), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => JSON.stringify([{ id: 'activity-1', demandCount: 2 }]), + }); + + await getActivities(); + const refreshed = await getActivities(); + + expect(global.fetch).toHaveBeenCalledTimes(2); + expect(global.fetch).toHaveBeenLastCalledWith( + expect.stringContaining('/activities'), + expect.objectContaining({ cache: 'no-store' }), + ); + expect(refreshed[0]?.demandCount).toBe(2); + }); +}); diff --git a/frontend/src/api.ts b/frontend/src/api.ts index feba347..ea9fd08 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -163,14 +163,20 @@ async function request(path: string, options: RequestInit = {}, signal?: Abor headers['Authorization'] = `Bearer ${sentAuthToken}`; } - const shouldUseCache = isGetRequest(options); + const isGet = isGetRequest(options); + // App data is shared and changes outside this device, so reads are fresh by + // default. A genuinely immutable endpoint may explicitly opt into the short + // in-memory cache with `cache: 'force-cache'`. + const shouldUseCache = isGet && options.cache === 'force-cache'; + const requestOptions: RequestInit = + isGet && options.cache == null ? { ...options, cache: 'no-store' } : options; const key = shouldUseCache ? cacheKey(path) : null; if (key) { const cached = getCache.get(key); if (cached && cached.expiresAt > Date.now()) { return cached.data as T; } - } else { + } else if (!isGet) { clearApiCache(); } @@ -194,7 +200,11 @@ async function request(path: string, options: RequestInit = {}, signal?: Abor } const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); try { - res = await fetch(`${API_BASE}${path}`, { ...options, headers, signal: controller.signal }); + res = await fetch(`${API_BASE}${path}`, { + ...requestOptions, + headers, + signal: controller.signal, + }); if (res.status === 204) { data = {}; } else { @@ -557,7 +567,11 @@ export interface GetClubOfficerMessagesResponse { } export const getClubMessages = (clubId: string, signal?: AbortSignal) => - request(`/clubs/${encodeURIComponent(clubId)}/messages`, {}, signal); + request( + `/clubs/${encodeURIComponent(clubId)}/messages`, + { cache: 'no-store' }, + signal, + ); export const sendClubMessage = (clubId: string, content: string) => request(`/clubs/${encodeURIComponent(clubId)}/messages`, { @@ -577,7 +591,7 @@ export const sendClubTyping = (clubId: string) => export const getClubOfficerMessages = (clubId: string, signal?: AbortSignal) => request( `/clubs/${encodeURIComponent(clubId)}/officer-messages`, - {}, + { cache: 'no-store' }, signal ); @@ -747,7 +761,7 @@ export interface GetClubChannelMessagesResponse { export const getClubChannelMessages = (clubId: string, channelId: string, signal?: AbortSignal) => request( `/clubs/${encodeURIComponent(clubId)}/channels/${encodeURIComponent(channelId)}/messages`, - {}, + { cache: 'no-store' }, signal ); @@ -1018,7 +1032,7 @@ export interface InboxSummary { } export const getInboxSummary = (signal?: AbortSignal) => - request('/inbox/summary', {}, signal); + request('/inbox/summary', { cache: 'no-store' }, signal); export const getMyPodHistory = () => request('/pods/mine/history'); @@ -1162,7 +1176,11 @@ function messagePageQuery(opts?: MessagePageOptions): string { } export const getMessages = (podId: string, opts?: MessagePageOptions, signal?: AbortSignal) => - request(`/pods/${podId}/messages${messagePageQuery(opts)}`, {}, signal); + request( + `/pods/${podId}/messages${messagePageQuery(opts)}`, + { cache: 'no-store' }, + signal, + ); export const sendPodTyping = (podId: string) => request<{ ok: boolean }>(`/pods/${podId}/typing`, { method: 'POST' }); @@ -1308,6 +1326,8 @@ export const registerPushToken = (token: string) => export interface NotificationPreferences { podJoin: boolean; + podInvite: boolean; + friendRequest: boolean; newMessage: boolean; meetupReminder: boolean; recapPrompt: boolean; @@ -1317,8 +1337,11 @@ export interface NotificationPreferences { clubKick: boolean; clubRoleChange: boolean; clubAttendanceOpen: boolean; + clubRsvpReminder: boolean; + clubOutreach: boolean; + clubRolePing: boolean; weeklyRecap: boolean; - /** Demand-pool pushes ("6 people want a boba run" / "a pod just went up"). */ + /** Demand-pool pushes ("6 people are up for a boba run" / "a plan just went up"). */ demandAlerts: boolean; } @@ -1547,7 +1570,7 @@ export const unfriend = (userId: string) => // Direct messages export const getMessageThreads = () => - request('/messages/threads'); + request('/messages/threads', { cache: 'no-store' }); export const getThreadByUser = (userId: string) => request<{ id: string; otherUser: import('./types').FriendUser; updatedAt: string }>( @@ -1563,7 +1586,10 @@ export interface GetThreadMessagesResponse { } export const getThreadMessages = (threadId: string, opts?: MessagePageOptions) => - request(`/messages/threads/${threadId}${messagePageQuery(opts)}`); + request( + `/messages/threads/${threadId}${messagePageQuery(opts)}`, + { cache: 'no-store' }, + ); export const markDMThreadRead = (threadId: string) => request<{ ok: boolean }>(`/messages/threads/${threadId}/read`, { method: 'PATCH' }); diff --git a/frontend/src/components/ui.tsx b/frontend/src/components/ui.tsx index 900231a..8c323ed 100644 --- a/frontend/src/components/ui.tsx +++ b/frontend/src/components/ui.tsx @@ -2363,7 +2363,7 @@ function TypingDot({ index, color }: { index: number; color: string }) { const animatedStyle = useAnimatedStyle(() => ({ opacity: 0.35 + progress.value * 0.65, - transform: [{ translateY: -2.5 * progress.value }], + transform: [{ translateY: -3.5 * progress.value }], })); return ( @@ -2416,21 +2416,21 @@ const typingStyles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', gap: spacing.sm, - paddingVertical: spacing.xs, + paddingVertical: spacing.sm, }, bubble: { flexDirection: 'row', alignItems: 'flex-end', - gap: 4, + gap: 5, borderWidth: BORDER_W, - borderRadius: radii.md, + borderRadius: radii.lg, borderBottomLeftRadius: radii.xs, - paddingHorizontal: 10, - paddingVertical: 8, + paddingHorizontal: 13, + paddingVertical: 10, }, dot: { - width: 6, - height: 6, - borderRadius: 3, + width: 8, + height: 8, + borderRadius: 4, }, }); diff --git a/frontend/src/context/SharedStateInvalidationContext.test.ts b/frontend/src/context/SharedStateInvalidationContext.test.ts new file mode 100644 index 0000000..30320ff --- /dev/null +++ b/frontend/src/context/SharedStateInvalidationContext.test.ts @@ -0,0 +1,14 @@ +import { scopesFromRealtimePayload } from './SharedStateInvalidationContext'; + +describe('shared state realtime payloads', () => { + it('accepts known scopes, removes duplicates, and ignores untrusted values', () => { + expect(scopesFromRealtimePayload({ + scopes: ['pods', 'clubs', 'pods', 'secret', 42], + })).toEqual(['pods', 'clubs']); + }); + + it('ignores malformed payloads', () => { + expect(scopesFromRealtimePayload({ scopes: 'pods' })).toEqual([]); + expect(scopesFromRealtimePayload({})).toEqual([]); + }); +}); diff --git a/frontend/src/context/SharedStateInvalidationContext.tsx b/frontend/src/context/SharedStateInvalidationContext.tsx new file mode 100644 index 0000000..233193a --- /dev/null +++ b/frontend/src/context/SharedStateInvalidationContext.tsx @@ -0,0 +1,104 @@ +import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { AppState } from 'react-native'; +import { clearApiCache, getMe } from '../api'; +import { useAuth } from './AuthContext'; +import { useRealtimeChannel } from '../hooks/useRealtimeChannel'; + +export const SHARED_STATE_SCOPES = [ + 'pods', + 'clubs', + 'friends', + 'users', + 'activities', + 'messages', + 'inbox', + 'admin', +] as const; + +export type SharedStateScope = (typeof SHARED_STATE_SCOPES)[number]; +type Versions = Record; + +const INITIAL_VERSIONS: Versions = { + pods: 0, + clubs: 0, + friends: 0, + users: 0, + activities: 0, + messages: 0, + inbox: 0, + admin: 0, +}; + +const SharedStateInvalidationContext = createContext(INITIAL_VERSIONS); +const VALID_SCOPES = new Set(SHARED_STATE_SCOPES); + +export function scopesFromRealtimePayload(payload: Record): SharedStateScope[] { + if (!Array.isArray(payload.scopes)) return []; + return [...new Set(payload.scopes.filter( + (scope): scope is SharedStateScope => typeof scope === 'string' && VALID_SCOPES.has(scope), + ))]; +} + +export function SharedStateInvalidationProvider({ children }: { children: React.ReactNode }) { + const { user, updateUser } = useAuth(); + const [versions, setVersions] = useState(INITIAL_VERSIONS); + + const invalidate = useCallback((scopes: readonly SharedStateScope[]) => { + if (!scopes.length) return; + clearApiCache(); + setVersions((current) => { + const next = { ...current }; + for (const scope of scopes) next[scope] += 1; + return next; + }); + }, []); + + const connected = useRealtimeChannel( + user ? 'app-state' : null, + ['state_updated'], + ({ payload }) => invalidate(scopesFromRealtimePayload(payload)), + ); + + useRealtimeChannel( + user ? `user-${user.id}` : null, + ['state_updated'], + ({ payload }) => { + if (!scopesFromRealtimePayload(payload).includes('users')) return; + clearApiCache(); + void getMe().then((latestUser) => updateUser(latestUser)).catch(() => {}); + }, + ); + + // Reconnect/app-resume safety net: realtime is the instant path, while this + // bounds staleness if the socket drops or the OS suspends the app. + useEffect(() => { + if (!user) return undefined; + const interval = setInterval( + () => invalidate(SHARED_STATE_SCOPES), + connected ? 60_000 : 20_000, + ); + const subscription = AppState.addEventListener('change', (state) => { + if (state === 'active') invalidate(SHARED_STATE_SCOPES); + }); + return () => { + clearInterval(interval); + subscription.remove(); + }; + }, [connected, invalidate, user]); + + return ( + + {children} + + ); +} + +export function useSharedStateVersion(...scopes: SharedStateScope[]): string { + const versions = useContext(SharedStateInvalidationContext); + return useMemo( + () => scopes.map((scope) => `${scope}:${versions[scope]}`).join('|'), + // `scopes` is intentionally represented as a stable primitive key. + // eslint-disable-next-line react-hooks/exhaustive-deps + [versions, scopes.join('|')], + ); +} diff --git a/frontend/src/dev/FoundationPreviewScreen.tsx b/frontend/src/dev/FoundationPreviewScreen.tsx index 4554762..80a0cad 100644 --- a/frontend/src/dev/FoundationPreviewScreen.tsx +++ b/frontend/src/dev/FoundationPreviewScreen.tsx @@ -20,6 +20,7 @@ import { useDockClearance, } from '../components/ui'; import { ClubEmptyState, ClubIdentityHero } from '../components/clubs'; +import { captureException, monitoringEnabled } from '../lib/monitoring'; import { spacing, useTheme } from '../theme'; const welcomeSpot = require('../../assets/illustrations/spot/onboarding/01-welcome.png'); @@ -29,6 +30,61 @@ const welcomeSpot = require('../../assets/illustrations/spot/onboarding/01-welco * It contains no production social proof and is reachable only when * EXPO_PUBLIC_UI_PREVIEW=foundation is set at build/start time. */ +/** + * Dev-only Sentry smoke test. + * + * Renders nothing unless a DSN is configured, so it is inert in normal dev and + * in any build without EXPO_PUBLIC_SENTRY_DSN set. + * + * "Send handled error" goes through captureException() — the same path + * ErrorBoundary uses. Appears in Sentry within a few seconds. + * + * "Throw unhandled error" throws during render, which ErrorBoundary catches and + * reports. This is the more realistic test: it exercises the native crash path + * and is what a real user-facing crash looks like. + * + * If nothing arrives in Sentry: confirm the DSN is set, and remember events are + * tagged with EXPO_PUBLIC_ENV, so a local run shows up under "development". + */ +function SentryTestPanel() { + const { typography } = useTheme(); + const [shouldThrow, setShouldThrow] = React.useState(false); + + if (shouldThrow) { + throw new Error('Oval Sentry smoke test — unhandled render error'); + } + + if (!monitoringEnabled) return null; + + return ( + + + + Events report to the environment named by EXPO_PUBLIC_ENV. + + +