Serein
Rust로 만든 가볍고 성능 좋은 Discord 전용 네이티브 클라이언트
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기We help students to find apartments, roomates and buy moving essentials.
저장소 README에서 가져옴
README 전체를 불러오는 중입니다.
README 전문 보기 ↗A private social network for university life — connecting students, clubs, events, campus news, housing, and the student marketplace in one place.
UniBuzz is a social network for verified university students. Students connect with each other and with their campus: they post photos, videos and reels, talk anonymously in Buzz, follow each other, and message friends, groups and their classes. Each university is its own private community, where students mainly see their own campus's people, clubs, events, discussions and news.
It launches first at Texas State University (TXST), with plans to expand to campuses worldwide.
Mission: connect students, clubs, organizations and campus communities in one place, so university life is more social, more connected and more convenient.
Facebook began as a network for college students and then became a network for everyone. UniBuzz brings back that university-only feel. It isn't a general social network, and it isn't an apartment or marketplace app. It's the digital home of campus life.
This is the product. New work, navigation and wording should start here.
| Feature | What students get |
|---|---|
| Orbit | The personalized home feed (the For you feed): photos, videos, posts and reels from their campus. |
| Buzz | Anonymous campus talk in separate Campus and Global feeds, with polls, per-thread aliases, votes and replies, moderated by Trust & Safety. |
| Reels | TikTok-style short videos, full screen. |
| Profiles and following | University-verified identities with major, graduation year, bio and classes, followers, and privacy controls. |
| Messages | Private chats, group chats and automatic class group chats, with voice messages, replies and view-once Snaps. |
| Search | Find students to follow first, plus the listings on Explore. |
Coming next, and part of the core:
These help with everyday student life and keep students in their community. They live together on the Explore tab and support the social network rather than define it.
| Tool | What it does |
|---|---|
| Housing | Apartments and subleases near campus, including listings from local apartment communities. |
| Roommates | Posts from students who have a room to share or are looking for one. |
| Marketplace | Buy, sell, trade or give away furniture, electronics, textbooks, bikes and more. |
/business for local businesses, from apartment communities to restaurants, cafés, shops, startups and services. They post updates, products, deals and events and housing listings, run Sponsored ads in For You, Reels and Housing after the UniBuzz team's review, answer student inquiries (students always write first), read analytics and run a team with roles (Business v2, migrations 49 and 50)./admin on the website and the separate CampConnect Admin staff app (see Buzz (anonymous)).The student platform is free. Revenue comes from businesses: advertising, promoted posts, paid listings and partnerships. Sponsored content is always labeled.
The design is modern, minimal and Apple-inspired, combining the best of Instagram, TikTok, Reddit and Discord.
@apartment-book/shared), the app's slug, URL scheme and bundle ID (apartmentbook), the Vercel project and old migration comments. They're history, not direction. Don't rename them, because that would break builds, deep links and the database history.Become the main digital community for university students worldwide: one place to meet people, join clubs, find events and opportunities, follow campus news, and handle everyday student life, from housing to buying and selling.
Built with Next.js 16 + TypeScript + Tailwind for the website and Expo (React Native) for the iOS and Android app, on top of Supabase (Postgres, auth, realtime chat, image storage). The backend is a hosted service that the apps talk to directly, and all data-access code lives in a shared package, so the website and the app share everything except their screens.
The layout follows the Facebook pattern: a top bar with three tabs (Home, Explore, Messages) plus the search box, a create menu and a profile menu; on phones the bar is Home, Explore, Messages, Profile at the bottom of the screen (the app's bar is Home, Explore, + (create), Messages, Profile, with Search and a Notifications bell that opens every notification in Home's top-right corner). Search finds people by name first (with a Follow button on every result), then apartments, roommates and items. Home itself has three top tabs, Orbit | Buzz | Reels (in the app under a UniBuzz wordmark, Instagram style; Posts is a fourth section that keeps its feed and its /?tab=posts address on the website but has no tab), see Home feed; the Explore tab holds Housing (apartments), Roommate and the Marketplace side by side, see Housing.
The sections below describe how each feature is built, the social network first and the Explore tools after it, then setup and deployment.
| Feature | How it is built |
|---|---|
| Home feed | Three swipeable tabs, Orbit first (Posts keeps its section and address without a tab). Posts and reels are feed_posts rows with likes, comments and saves; Buzz threads live in buzz_* tables that the API cannot read directly, only the buzz_* database functions can. The Orbit blend (posts and reels; Buzz stays on its own tab) is pure code in packages/shared/src/for-you.ts. See Home feed |
| Following | Follow people from their profile, like Instagram. Every profile shows follower / following counts to everyone, but who follows whom is private: on your own profile the counts open your Followers and Following lists, on anyone else's both open one list, Mutual friends (the people who are friends with both of you, by name), with a note that their followers and following are private; signed out, you are asked to log in instead. Home → Posts has a "Following" filter with only the posts of people you follow (in the app, a row of the feed dropdown in the Home top bar), and a notification tells you when someone starts following you; blocking someone ends the follow both ways. Migration 14 (supabase/migrations/20260923000000_follows.sql) adds the follows table and the follow_stats / following_posts functions; migration 21 (supabase/migrations/20261010000000_private_follow_lists.sql) lets only the two people in a follow read it (the counts come from follow_stats: the same totals for every viewer, whoever they have blocked, so a block cannot be used to find out whom someone follows) and adds the mutual_friends() function. The view each person gets and its wording live in packages/shared/src/profile.ts (followListView), the query in listMutualFriends |
| Profile | TikTok style, the same on the website (/profile/[id], /profile/me) and in the app (the Profile tab and /profile/[id]): the photo (yours with a "+" to change it), then three lines in this order: the full name, the major, class year and university ("CS ’28 @ Texas State University": profileStudyLine() and majorAbbreviation() in packages/shared/src/profile.ts, which shortens "Computer Science" to CS and keeps what was typed short already) and the bio; under them the @username with a QR button, Following · Followers · Likes (every like on their posts, reels and listings), Edit profile / Share profile / Find friends on your own profile and Follow / Message / ••• (block, report) on someone else's, then the tabs Posts, Classes, Reels, Saved, Liked and Listings over a three-column grid with view counts (in the app the tabs are icons with a sliding underline, and the open one carries a mark of who can see that section: a lock, two people or a globe). Listings holds their apartments, roommate posts and items for sale, each square with its kind and title (on the website the owner also sees the rented, sold or found ones, marked so). Every profile gets an @username made from the name, changed in Settings (unique; 3 to 30 letters, numbers, dots and underscores); people search matches it too. Classes are listed per semester (this one first, up to 12 each) with a section, the days and times it meets and the room; your own are added from two boxes, the subject and the course number ("CS" and "3358", kept as the one code "CS 3358" the database checks: and its checks in ), and the form closes once the class is in, a line by "Add class" saying so until the button brings the form back; in the app a hold on a class card offers Edit (the same form, filled in; ) and Delete, which asks first, while the website keeps its remove cross; listing a section puts you in its course chat, a group chat shared by everyone at your university taking that section that semester (the first classmate's listing creates it, removing the class leaves it, and a notification tells you about both). Who can see Classes, Saved, Liked and Listings is each person's choice of Everyone, Friends (people you follow who follow you back) or Only me, set from the line at the top of the tab or in Settings → Privacy; the defaults are Friends for Classes and Only me for Saved, Liked and Listings (migration 37), and a visitor without access (or anyone blocked either way) gets no tab for that section at all: no icon, no lock, and its address opens Posts. The owner always sees every tab. The listings themselves stay public in Explore; the setting covers the profile's Listings tab. Pin up to three of your posts or reels to the top of the grid from their ••• menu (in the app also by holding the square). Share profile, or the QR button, opens a sheet with the QR code of the profile's web address, Share and, on the website, Copy link. Migration 18 () adds and the three visibility columns, the table, (three pins at most, checked by trigger) and the , , , and functions; likes stop being readable row by row, so counts come from and the "Liked by" faces from , which honours each liker's Liked setting. The wording and helpers live in , the queries in |
Photos are the product, so the pipeline never degrades them:
images.qualities, deviceSizes and formats in apps/web/next.config.ts). The full-screen viewer shows the original file itself, and its "Original" button opens it directly.image_meta next to the listing. Feeds show the blur instantly and fade in the real photo, and layouts never jump.Vercel's Hobby plan optimizes up to 5,000 distinct source images per month. If the site outgrows that, upgrade the Vercel plan or move rendition generation to Supabase image transformations (Pro plan).
A notifications table that only its recipient can read, mark or delete; rows are created by database triggers so the mobile app gets them for free: a new message (repeated messages in one chat refresh a single unread entry; it rides along for the push but shows only in Messages, see below), someone saved your listing, someone started following you (one entry per follower, however often they follow and unfollow), someone replied to your comment, someone sent you a Snap (one entry per chat until it is read, see Snaps), a call you missed (call, see Calls), and a new place pinned within 2 miles of your campus (opt-in per person in Settings). The top bar shows a bell with a live count and dropdown; /notifications lists everything except new messages and Snaps, which belong to Messages alone (its unread counts and badges, and the push): listNotifications() and getUnreadNotificationCount() in packages/shared/src/queries/notifications.ts leave out MESSAGE_NOTIFICATION_TYPES, and since migration 37 so does unread_notification_count(). Since migration 36 (supabase/migrations/20261029000000_push.sql) every notification notify() makes also goes to the person's phones: send_push() posts it with pg_net to Expo's push service, to the Expo push tokens the app stores in device_push_tokens (a real device, signed in, with permission granted), with the notification's type and link as data so a tap opens the right screen; nothing is sent where pg_net is missing, and a push that cannot be sent never fails what it rides on. With Expo's enhanced push security turned on, put the project's access token in Vault as expo_push_token and it goes along.
A Snap is a photo or a video (up to 30 seconds) taken with the phone's own camera from the app's + menu (Post, Reel, Buzz (anonymous), Snap) and sent straight into the direct chats of the people you pick, each of whom can open it once. The camera opens over whatever is on screen; what it takes fills the screen on black (a photo over a blurred copy of itself) with close and "take another" at the top and the Aurora Send To at the bottom. Send To rises as a sheet with your direct chats (the most recent first) and your friends (people you follow who follow you back), and a search over them; pick up to 50 and the round send button shows the upload going up, then the paper plane flies off and a tick lands. Each person gets one message in your direct chat with them (the chat is made when there is none), the inbox reads "Sent a Snap", and a snap notification says "Sent you a Snap" (a muted chat stays quiet). A chat never shows a Snap's picture: it is a bubble at the Snap's own shape (the app draws it like a sent photo, the same size) showing only a tiny preview in its own colours (made on the phone when it is taken, from the photo or the video's first frame), softly visible under a light pane of frosted glass (colours and vague shapes, drifting slowly while the Snap waits to be opened, never a detail: the preview is 32 pixels on its long side), with "Tap to view" for its recipient and "Delivered" for its sender; tapping opens it full screen once (a photo until you tap, swipe it down or close it; a video plays through once with a bar along the top), and from then on both see "Opened" over the same blurred preview (in the app at seven tenths of the size): since migration 32 opening it leaves the preview on the message, while a Snap that goes unopened (it expires, or its sender's account goes) still loses it at once (migration 29). A Snap nobody opened within 31 days expires and both see "Expired". Under every Snap both sides see what happened to it, Snapchat style, with the time: delivered, opened, a screenshot or a screen recording (the sender is told at once with a snap_activity notification), all as the same quiet grey note, never a warning, deleted (the sender can take a waiting Snap back), expired; a tap opens the whole story, which stays after the Snap is over (messages.snap.activity, migration 33). The sender is never told more than the viewing phone can detect: an iPhone reports screenshots and blacks out recordings, an Android phone blocks both, and anything else reports that it can detect nothing. The website shows the same cards and says to open the app to view one.
The database enforces all of it (migration 28, supabase/migrations/20261020120000_snaps.sql). The files live in a private snaps bucket that no client can list, replace or delete: the sender uploads into their own folder, and a recipient can read a file only from opening it until they close it, two minutes at most (open_snap() / close_snap()), never after it expired and never twice; the app loads it through Storage's signed-in download with the viewer's own session, so every request is checked against that and no link outlives the view. The snaps, snap_recipients and snap_purges tables have no policies and no grants, and messages.snap is written only by send_snap() and open_snap() (a trigger refuses it from the API). send_snap() takes only friends and people you already have a direct chat with, nobody blocked either way. Every 10 minutes purge_snaps() (pg_cron) forgets every Snap that everyone has opened (10 minutes on), every Snap that expired and every file uploaded but never sent (an hour on), and deletes the files through the Storage API with pg_net, using the project URL and service key that npm run db:setup keeps in Vault; a file is checked off only once Storage no longer has it, and a chat whose Snap went unopened (it expired, or its sender's account was deleted) is told at once. The moment the last person a Snap went to closes it, close_snap() deletes its file itself (migration 30) rather than waiting for the purge. One thing the database cannot close: Supabase's CDN keeps serving a file it has handed out to the same person, under any of their sessions, for about a minute after the file is deleted (and until then for as long as it exists, whatever the policies say by now); nobody else can get it that way (the CDN checks who is asking; this was measured live on 2026-10-23). So with a tampered app the recipient who just watched a Snap could fetch the same bytes once more for up to a minute, and no one else ever can. The app keeps a viewed photo in memory only, never on disk.
A story is one photo or video shared for 24 hours, Instagram's way. In the app, a row of rings sits under Home's Orbit | Buzz | Reels tabs, at the top of Orbit: Your story first (your photo with a +; once you have one, your ring, which opens it), then everyone whose stories you may see, those with something unseen first in the aurora ring, the rest in grey. A tap opens them full screen: a photo for five seconds, a video for its length (60 seconds at most), a bar for each along the top; tap right to go on, left to go back, hold to pause, swipe sideways to the next person, pull down to close. Your own stories show how many people have seen each, a tap on which lists them, and a bin to take one down. A story is shared from the publish screen of the + create flow: Your story beside Post, with the post's own audience choice (Texas State, Global, Friends or Selected people) and editing tools; each photo and video becomes a story. The website shows no stories yet.
Migration 58 (supabase/migrations/20261110000100_stories.sql) keeps every rule on the server: stories with a restrictive select policy that applies migration 44's audience rule (can_see_story(): campus by the verified email domain, friends both ways, story_viewers for selected people, written by set_story_viewers()), the expiry and blocks; a trigger stamps the author, the time and the 24 hours whatever the client sent; only the author deletes, nothing updates; story_views, written by mark_story_seen() alone (once per viewer, never the author's own, never for a story the viewer may not see), read in full by the author and one's own rows by a viewer; stories_sweep() deletes stories a day past their expiry (pg_cron hourly). supabase/tests/stories.test.mjs runs all of it in PGlite; packages/shared/tests/stories.test.mjs the tray's order and the viewer's timing.
Paused for now. Until there is an Apple Developer Program membership no iPhone build can carry calls, so the app keeps every call button, call row and call screen but a tap shows a quiet "Coming Soon" notice under the chat's bar instead of ringing anyone: nothing is written to the database, no ring is listened for, and the Join banner stays hidden (CALLS_PAUSED in apps/mobile/src/lib/calls.ts). Everything below stays in place, deployed and tested (the database, the livekit-token function and its LiveKit secrets, the native modules and their config); a build bundled with EXPO_PUBLIC_CALLS_ENABLED=1 turns calls back on. The rest of the app runs in Expo Go.
Audio and video calls, FaceTime style, in every chat: a direct chat rings one person, a group (a class group too) rings everyone in it, and anyone can join a call that is going on. The chat's top bar has Audio call and Video call beside Info; a call opens on its own dark stage with the other person's video (or their photo) filling the screen and your own camera in a small window, a grid for a group (the people it is still ringing faded, "ringing" under their names), their name and the call's standing at the top ("Calling…", "Connecting…", the running time) and a glass bar of controls along the bottom: mute, speaker, camera on and off, flip camera, the people in the call (a group), and the red End button. The caller hears a ringback; the phone being called rings and buzzes (respecting the silent switch) and shows a full-screen incoming call with the caller's photo and Decline / Accept over whatever is on screen; a ring that nobody answers within 45 seconds stops on its own and the call is missed. Every call is a row in the chat, the same on both sides and on the website, that follows the call live and reads, once it is over, "Audio call · 3:24", "No answer" (for the caller), "Missed video call" (for whoever did not pick up, in red, with a call notification), "Video call declined"; a tap on it calls back, or joins the call if it is still going on, and a glass pill under the chat's bar offers to Join a call going on in the chat that you are not in. Leaving the call screen in any way hangs up; when everyone else has left, yours ends too.
The database keeps every call and who stands where in it (migration 35, supabase/migrations/20261028000000_calls.sql): the calls and call_participants tables, readable only by the chat's members, written only through start_call(), answer_call(), decline_call() and leave_call() (get_call() and current_call() read them with the participants' names and photos); messages.call, the call's row in the chat, is written only by those functions (a trigger refuses it from the API) and updated as the call goes on, so the chats and the inbox preview follow it live over the existing realtime channel; a ring that was never answered is closed as missed by the next call anyone makes (expire_stale_calls(), 60 seconds on), and an active call left hanging is closed after 6 hours; the call's own message sends no "new message" notification, and only a missed call sends one (call, deduplicated per call). supabase/tests/calls.test.mjs runs all of it in PGlite (ringing, answering, declining, cancelling, the ring running out, a group where some decline and some join late, strangers and the API refused, the row's text on both sides); packages/shared/tests/calls.test.mjs the row's wording.
The sound and picture travel over LiveKit (WebRTC, end-to-end over DTLS-SRTP), one room per call named by the call's id. The livekit-token Edge Function (supabase/functions/livekit-token/index.ts) checks the caller's session, asks the database with get_call() that they are in the call (state joined) and mints a two-hour token for that room alone; without a token nobody connects, so the chat's membership rules are the call's. To set it up: make a LiveKit Cloud project (free tier at cloud.livekit.io; or run a LiveKit server), then npx -y supabase@latest secrets set --project-ref dskbzoqreandwwpxiplh LIVEKIT_URL=wss://… LIVEKIT_API_KEY=… LIVEKIT_API_SECRET=… with SUPABASE_ACCESS_TOKEN set (the function itself is deployed with npx -y supabase@latest functions deploy livekit-token --project-ref dskbzoqreandwwpxiplh, no Docker needed; the Dashboard's Edge Functions page takes the same file). Until the secrets are set the function answers "Calls aren't set up on the server yet." and the call screen says so. SUPABASE_ACCESS_TOKEN=sbp_… node apps/mobile/e2e/call-lifecycle.mjs runs the whole signalling lifecycle against the live project with throwaway accounts, asks the function to check its key pair against LiveKit ({"check": true} answers whether LiveKit accepted a server token, never a secret) and reports whether a call token can be minted, leaving nothing behind. The apps' media module (@livekit/react-native, @livekit/react-native-webrtc, with their Expo config plugins in app.json: microphone and camera permission texts, background audio, Android's communication audio mode and Bluetooth permissions; the camera text is written twice, as ios.infoPlist.NSCameraUsageDescription and as expo-image-picker's cameraPermission, and the plugin's is the one iOS shows, so the two stay the same: video calls, taking photos and recording videos) is compiled only into the app's own builds, never into Expo Go: in Expo Go calls still ring, answer and show their rows, but no sound or picture flows, and the call screen says the build cannot carry calls. Ringing reaches a phone over realtime while the app is open (and any ring still waiting is picked up when the app comes back to the front, within the 45 seconds); a phone whose app is in the background or closed is rung by a push (migration 36: start_call() sends "Incoming video call · Maya is calling" to everyone it rings, with the app's own ring sound on the Calls channel, dropped unseen once the 45 seconds are over), and tapping it opens the incoming call in the app if it is still ringing. That is a notification, not a system call screen: the full-screen native ring of a locked phone (iOS CallKit with PushKit, Android's full-screen intent) is not built, and would need an Apple Developer account's VoIP certificate. In a call, an iPhone keeps the sound going in the background () and pauses the camera, as every app's does; an Android phone keeps it only as long as the system lets a backgrounded app use the microphone (newer versions cut it after a while without a foreground service, which is not built either). A refused microphone or camera does not end the call: the screen says to allow it in Settings.
Home has three tabs in TikTok's layout, left to right: Orbit | Buzz | Reels; Posts is a section without a tab (HIDDEN_HOME_SECTIONS): its feed, its /?tab=posts address and every post feature stay, and Orbit keeps blending posts (and reels) in. They are swipeable: in the app the three pages sit side by side in a pager, and on the website a sideways touch swipe across the section body moves to the neighbouring tab (touch screens only; a multi-photo carousel keeps its own swipes). In the app the floating top bar over the feed has two rows, like X's: your photo in a teal-to-violet ring on the left, which opens your profile, the UniBuzz wordmark (HOME_BRAND) in the middle (it reads "Following" while that feed shows), and Search and the Notifications bell on the right (Search opens the people search; the bell opens the notifications screen and its badge counts the unread); then the three labels spread across the second row, highlighted like X: the active one bold with a rounded violet underline as wide as the label, sliding with the finger over a hairline across the whole bar; moving between sections, by tap or swipe, plays no haptic. Under the labels sits a Campus | Global toggle, a small segmented pill whose thumb slides to the choice: Campus is your own university, Global every university (Following joins it only while Posts has a tab: it keeps Posts to the people you follow and moves to Posts); the row is not drawn for someone without a university. For now the toggle is hidden everywhere, under the labels and in the full-screen viewer (SCOPE_TOGGLE_SHOWN in apps/mobile/src/lib/home-scope.ts is false): the bar is just its first two rows and every feed stays on your own campus; the toggle's code is all kept, and setting the flag back to true brings it back. The bottom bar reads Home, Explore, Messages, Events, Profile, with no "+": in the app creating starts from the floating "+" at the bottom right of the feed (X's compose button; Buzz has its pen there) and Upload in the menu under your photo, and Home's top bar has no "+", over Reels included. The bar scrolls like Instagram's: it slides up out of the way as the page on screen scrolls down and back the moment the page scrolls up, however far down it is (a swipe or a tap between pages brings it back too; Reels never moves it). Explore's Housing | Roommate | Marketplace row and the pills under it (the Marketplace's categories among them) do the same under its fixed top bar. The bottom bar floats over the content as a rounded glass pill (real liquid glass on iOS 26, a translucent fill elsewhere), and every tab keeps room clear under it; while a page of Home (For you, Buzz), Explore or Events scrolls on it collapses like Reddit's to a circle holding that tab's icon (Home's, Explore's or Events'), the other tabs fading, the "+" becoming the same circle opposite it, and it returns whole as soon as the page scrolls back (apps/mobile/src/lib/tab-bar-collapse.ts). The campus and the Following choice live only in that menu (, shared by Orbit, Buzz, Posts and Reels): the pages have no filter chips of their own. Orbit is the landing tab and keeps the clean address, the others live at , and . The order and the addresses come from and in , so the website and the app always agree.
| Tab | What it shows |
|---|---|
| Orbit | Posts and reels blended into one feed; anonymous Buzz threads are never part of it, they live only on the Buzz tab. blendForYou in packages/shared/src/for-you.ts spreads the reels between the posts in a fixed pattern (post, post, reel, post, post, post, reel, post), each kind keeping its own order (newest first), so a page looks the same on every device and every reload. Each kind is paged on its own (FOR_YOU_PAGE: 6 posts and 2 reels per page), so page two never repeats page one; a kind that runs short is skipped and the other fills in. Works signed out |
| Buzz | UniVerse's anonymous campus feed, ported whole: Campus and Global as separate feeds, per-thread emoji aliases ("☄️ Anon Comet"), Hot / New / Top, search, polls, photos or one video, each card's top reply, replies threaded like Reddit's (two levels on the website) and My buzzes, in the dark "Phosphor" look. Every buzz and reply passes the moderation engine before it is stored, and the buzz_* tables cannot be read through the API at all: only the buzz_v2_* database functions decide what leaves the database. See Buzz (anonymous) |
| Posts | Photo-first rows (in the app every post, roommate post and apartment shares one layout: avatar, name and " · Oct 2" up top, the words next, folded to three lines with "more…" / "less" (a roommate post's description stays hidden whole behind "more…"), then the photo in a rounded frame inset from the screen edges, no card box) with swipeable carousels (the slide dots on their own row under the photo), heart / comment / share / bookmark (the heart pops when tapped and a double tap on the photo likes with a big heart burst; in the app the like also buzzes the phone through expo-haptics), "Liked by …", a caption with hashtags, the newest comment and, behind the comment icon, the thread itself, TikTok style: "N comments" with a Top / Newest sort, a heart with its count on every comment (a dislike stays private), Reply, replies folded behind "View N replies", and a hold on a comment for Reply / Report / Block / Delete |
| Reels | Vertical full-screen video (reels and apartment video tours), one at a time, autoplay with a shared sound button; the heart in the side rail and a double tap on the video like it with the same heart burst (and buzz in the app), the comment icon opens the same thread in a drawer |
Checks for the Home feed:
| Check | How |
|---|---|
The Orbit blend, the Home and Housing tab order and addresses, the snapshot a shared post carries into a chat (packages/shared/tests/share.test.mjs) and the profile helpers (packages/shared/tests/profile.test.mjs: semesters and classes grouped by term, class codes and usernames checked like the database checks them, the who-can-see wording, which follower list a viewer gets and its wording, grid squares) and calls (packages/shared/tests/calls.test.mjs: the call read from a message, durations, the inbox preview and the row's text on each side) and activity status (packages/shared/tests/activity.test.mjs: "Active 1 min ago" to "Active 2 days ago", "yesterday" by the calendar (also the nights the clocks change), never "0 min", a few minutes of clock skew, nothing before presence is known or without anything to show, "Active now" only with a recent stamp (and with no time for someone who has not written to you); the presence store on its private channel, with its readiness, appearing only while a fresh stamp is in, the grace before a tab stops appearing and leaving at once on a phone, coming back (no re-appearing after a hide made elsewhere), Realtime's presence-call limit, the minute heartbeat and block list, a status hidden on another device, a refused join, a session that ended and one only being renewed, rejoining after a close, a dropped connection short or long, people blocked either way; activity_status asked in batches of 100) (unit tests, no network, Node 22.18+) | npm run test:shared |
Migrations, row-level security, Buzz v2 (supabase/tests/buzz-v2.test.mjs) and Trust & Safety (supabase/tests/trust-safety.test.mjs, see Buzz (anonymous)), following (supabase/tests/follows.test.mjs: the follows policies, follow_stats, following_posts, the follow notification, blocking, account deletion) and comment threads (supabase/tests/comment-threads.test.mjs: replies and their parent checks, post_comment_vote() and the score, the reply notification, deletion with its replies, blocking) and comment likes (supabase/tests/comment-likes.test.mjs: the hearts count only the likes, a dislike stays private, the counts belong to the server) and shared posts (supabase/tests/shared-posts.test.mjs: friends are mutual follows minus blocks, a share without words, the "Sent a post" / "Sent a reel" previews and notifications, plain messages unchanged) and the profile page (: usernames, the who-can-see settings and their defaults, classes per semester, the Saved and Liked lists behind those settings with like counts unchanged, blocking, profile numbers, view counts, at most three pins) and private follower lists (: a follow readable only by the two people in it, the owner reading all of theirs, giving every viewer the same totals whoever they have blocked (zeros only across a block with that profile), mutual friends (exactly the friends both have, by name; nothing signed out, on your own profile or across a block), closed to the API while friends-only sections still open, and , , following, its notification and blocking unchanged) and activity status (: closed to the API signed in and out, heartbeats that stamp only people who show their status ( from old builds too) and leave no trace on , not even a lock, ' and ' system columns closed to the API (an anonymous Buzz reply can no longer be matched to its author or dated), the grant following whatever columns each table has, hiding deleting the time and stopping "Delivered", showing it again only through (a stale save cannot), moving only for something new (a message sent dated ahead is dated when it was sent, so it cannot keep "Seen" or "Delivered" moving), chat members changing only their receipts and a group's name (even with no filter), a scheduled notification out of reach of filterless updates and deletes, rule by rule (hide yours and you see nobody's, someone hiding theirs, blocked either way, no shared chat, an empty chat or a group nobody wrote in giving "recent" with no time, what a group said before you joined, leaving a group, cut to the minute, recent, 100 ids at most, refused signed out), , emptied for good, and the presence policies against a stand-in realtime schema) and Snaps (: the private bucket and its upload rules, who a Snap can go to, every check in , one message, preview and notification per person and none in a muted chat, the one view with its two minutes and , expiry, blocking, a sender's account going, and against stand-ins for pg_net and Vault) and calls (, see ) and push (: every notification pushed once per device, the ring push with its channel, sound and 45-second life, a muted chat quiet, no tokens no request, the Vault access token, the API refused), offline in PGlite |
Buzz is UniVerse's Buzz, ported whole into the app and the website: the same feeds, aliases, rules, moderation and look, running on Supabase instead of UniVerse's Express + SQLite server.
| Piece | Where |
|---|---|
| Buzz database: tables, aliases, the serializer, every read and interaction | migration 42, supabase/migrations/20261103000100_buzz_v2.sql |
| Trust & Safety: reports, the moderation queue, sanctions, appeals, the audit log, the staff console's functions | migration 41, supabase/migrations/20261103000000_trust_safety.sql |
| Moderation engine: rule packs, the private-info detector, policy, what the writer is told | packages/moderation (server only, never bundled into the app) |
| Write API for new buzzes and replies | apps/web/src/app/api/buzz/**, apps/web/src/lib/buzz-server/** |
| Types, copy, limits, helpers and the client calls both apps use | packages/shared/src/buzz/**, packages/shared/src/trust-safety |
| Screens | website apps/web/src/components/buzz/** and /buzz/*; app apps/mobile/src/components/buzz/** and src/app/buzz/* |
| Staff console | website only, /admin (apps/web/src/app/(app)/admin/**) |
What it does:
BUZZ_GUIDELINES_VERSION (packages/shared/src/buzz/constants.ts) together with public.buzz_guidelines_version().treeParentId, migration 20261108000000_buzz_reply_tree.sql). The website, and the app on a database without that migration, show two levels; deeper replies are flattened and show who they are replying to.20261108000200_buzz_prompt_threads.sql; the app shows it, the website shows prompt threads as ordinary buzzes for now.BUZZ_CATEGORIES (packages/shared/src/buzz/constants.ts), and the database refuses anything else; to change it, change both (migration has the database's copy; checks they agree).The anonymity firewall:
Closed tables. The buzz_* tables cannot be read through the API. Every read goes through buzz_v2_* functions that build the outgoing JSON field by field, in one serializer. It sends aliases, never an author id, name or photo, and cuts times to the minute. Only the client functions are callable; every internal helper (the alias book, the author lookup) is revoked from every API role.
Jitter. New threads publish 0–3 minutes late and replies 0–45 seconds late, so a buzz can't be timed against someone's activity. For the same reason Buzz has no realtime; the screens refresh every 30 seconds.
Held items. Something held or hidden for review is shown only to its author. "Not found" looks the same whether an item doesn't exist, is held, or is someone else's.
Notifications about Buzz never carry who acted or anything that identifies them. A reply notification quotes your own buzz, never the reply.
Media. Photos are stored under uploads/buzz/anon/<random> with location and camera details removed, and that folder cannot be listed. Videos go through Mux with nothing linking them to the uploader.
Blind moderators. The staff console shows content, never authors, and nothing a moderator does to a buzz shows on anyone's account page. Only an admin can unmask an author, and it takes:
The audit row is written before anything is revealed, the other admins are notified, and the identity masks again after 5 minutes. Admins are limited to 10 unmasks a day.
Moderation and Trust & Safety:
The write API. Every new buzz and reply goes through the website's write API (POST /api/buzz, POST /api/buzz/<thread>/replies), which runs the moderation engine before anything is stored. The database accepts Buzz writes only from that server (service-role functions), so neither the app nor a script can skip moderation. Blocked text gets an explanation linking the Community Guidelines (/guidelines). Self-harm gets support resources as well.
Optional AI second pass. With MODERATION_AI_API_KEY set, the server asks Claude (claude-opus-5-5) to re-check what the rules allowed, after the response has gone out. It can only escalate (flag for review). It never allows something the rules blocked.
Reports and sanctions. Reports (12 reasons) go to the staff queue. Sanctions (warning, mute, suspension, ban) show on Settings → Account status, where the person can appeal once, within 14 days. A different staff member from the one who acted reviews the appeal.
Sanctions outside Buzz. As in UniVerse, sanctions also hold outside Buzz:
A suspended or banned account can still sign in and read, because UniBuzz has no restricted session. The console lists this and its other differences from UniVerse under Limitations.
The staff console (/admin, website only) has Queue, Users, Appeals, Audit, Metrics and Rules. Everyone who isn't staff gets a plain 404 there. Staff confirm it's them with an authenticator app first (/two-factor, two-factor sign-in): the database refuses every console function without it.
With CampConnect Admin. Production also runs CampConnect Admin, the separate staff app whose migrations reached the live project from outside this repo (supabase/applied-outside-repo/README.md). Where its tables exist the two work as one:
Setting it up:
npm run db:setup with an access token for this: that mode also rewrites the auth Site URL. Use its database-URL mode (SUPABASE_DB_URL=… npm run db:setup), or run the two files in order in the SQL editor and record each in public._migrations. First check that the live project matches this repo plus the outside migrations: SUPABASE_ACCESS_TOKEN=… node supabase/applied-outside-repo/verify.mjs --live --except 20261103000000_trust_safety.sql,20261103000100_buzz_v2.sql. Once migration 42 is live, the old Buzz functions (buzz_feed, buzz_get, buzz_create, …) answer "update the app", so redeploy the website and publish the app update right after (cd apps/mobile && npx eas-cli@latest update --channel preview). Existing threads and replies are converted and kept: threads without a campus become Global.SUPABASE_SERVICE_ROLE_KEY=… in apps/web/.env.local and in Vercel (Production and Preview). Never make it a NEXT_PUBLIC_ variable. Without it, reading Buzz works but posting answers that Buzz isn't set up (503).insert into public.staff_members (user_id, role) values ('<your user id>', 'admin'); in the SQL editor (your user id is under Authentication → Users; moderators get 'moderator').MODERATION_AI_API_KEY for the AI second pass.EXPO_PUBLIC_SITE_URL (apps/mobile/.env, default the live Vercel site) must point at a deployment that has this code and the service-role key.select public.buzz_v2_housekeeping() every few minutes another way. It closes due polls, tells their authors, and clears old rate-limit rows.On a development server, /?tab=buzz&buzzFixtures=1 shows every Buzz screen with sample data, and so do /buzz/search?buzzFixtures=1 and /buzz/mine?buzzFixtures=1. Other variants: gate, muted, new, small, empty, error, blocked, held, support. Production builds leave the switch out.
Tests:
npm run test:moderation: the engine, 422 tests.npm run test:shared: the helpers.npm run db:test: includes trust-safety.test.mjs and buzz-v2.test.mjs, offline in PGlite.scripts/e2e-home-feed.mjs: checks Buzz v2 on a live project once the migrations are applied.Known limits:
admin.spam pack flags some ordinary phrases, such as "do i have to pay upfront for housing". Staff can turn that pack off under Rules.Last-Modified), so a photo buzz can be dated to the second despite the publication delay. UniVerse's uploads behave the same. Serving Buzz photos through the website without that header would close it.Housing is one main tab, labelled Explore in the bar with a compass icon, with two halves, left to right: Housing | Roommate (apartments and roommates), and the Marketplace as a third page right after them (its grid, hearts, category pills, search and "Sell an item" moved in unchanged). Like the Home tabs they are slidable: in the app the three feeds sit side by side in a pager under the tab's own bar, laid out like the inbox's ("Find", the labels with the sliding underline, the inbox's search box searching the page on screen with a round filter button beside it (a sheet of the page's own filters: sort, bedrooms, rent, distance from campus, furnished, pets and video tours for rooms; budget, who the post is open to, distance and video for roommate posts; sort, condition and price for items), then the pills: All, All | Has a room | Looking for a room, or All and the item categories) (tap a label or swipe; no haptic, like Home's), and on the website a sticky Housing | Roommate | Marketplace bar sits under the navbar and a sideways touch swipe across the page moves to the neighbouring section (touch screens only; the photo rail, the map and a multi-photo carousel keep their own swipes). Housing is the landing half. The website keeps the /apartments, /roommates and /marketplace addresses, so listing links, filters and the ?university= query are unchanged; the Explore tab in the navbar links to /apartments and stays highlighted on both. In the app the floating "+" follows the visible half: "List an apartment" on Apartments, "Create roommate post" on Roommates, "Sell an item" on Marketplace. The order and the addresses come from HOUSING_SECTIONS, DEFAULT_HOUSING_SECTION and housingSectionHref in packages/shared/src/constants.ts, so the website and the app always agree.
Events is the fourth tab, with a calendar icon. It shows the campus's events in one place and lets people save them, share
them, register and follow the organizations behind them. Two pages sit under the title, For you | All events, with the
inbox's search box and one row of pills under them: the date pills (Upcoming, Today, This week, Weekend) and the category
pills (Academic, Career, Social, Sports, Music, Arts, Tech, Wellness, Cultural, Service, Faith, Other). Every time is shown on
the campus's own clock (universities.timezone).
Where events come from. Two sources only, so nobody can fill a campus's calendar on their own:
/admin/events, admins add and change feeds,
every staff member can press Sync now). The website syncs them (apps/web/src/lib/events-server/sync.ts, called by
/api/events/sync from Vercel Cron, daily on the Hobby plan, and by the console): it reads the calendar
(packages/shared/src/events/ics.ts, rss.ts: folded lines, local and UTC times, all-day events, weekly and monthly
rules, overrides, cancellations), plans the difference against what the database has (events/sync.ts: new rows,
changed columns, future events the feed dropped marked cancelled, never deleted), skips an event another feed or an
organization already lists (same title at the same moment on the same campus), guesses a category from the calendar's
own words, and writes with the service role. A feed's event a staff member hid stays hidden through later syncs.pending: its members see them, nobody else does. Verifying
publishes them; unverifying or hiding the organization takes them back.What people do. Save (the bookmark; /events/saved), share (the native share sheet with the website's address),
open the registration link, follow an organization, pick interests. Each of those feeds the ranking below. Opens,
registration taps and shares are counted once a day per person (record_event_click()); only the counts leave the
database, and a student reads only their own rows.
For you. One formula in code (rankEvents, packages/shared/src/events.ts), the same in the app and on the website,
deterministic: a picked interest (+3), the categories the person keeps saving and opening (up to +2.5), a followed
organization (+4), how soon it is (+3 tonight, half of that in a week), how many others saved or opened it, a picture, a
verified organization; a cancellation counts against. Among the first five rows one organization does not take a third
row in a row while something else scores within a point. The pills always apply on top. Signed out, time and popularity
alone.
Staff. /admin/events in the Trust & Safety console: the counts, the feeds (status of the last sync, its error, Sync
now, add, edit, delete), the organizations (verify, unverify, hide, restore, with a note) and the events (hide, cancel,
restore, with filters). Every change is an audit_log row (organization.verify, event.hidden, event_feed.create …)
written in the same transaction by the events_console_* functions, which answer non-staff "not found" and need
two-factor sign-in like the rest of the console.
Multi-campus. Every event, feed and organization belongs to a university; every university has a time zone; the Events tab shows the signed-in student's campus (everything, signed out). A new campus is a seed row with its zone.
UniBuzz Business is a website of its own for landlords and local businesses, served at /business on the main site, and at its own domain once one is set (NEXT_PUBLIC_BUSINESS_SITE_URL; none yet). It has its own login and sign-up but shares the backend and the auth with the student apps; there is no mobile app for it, and the student app shows a business account only a notice pointing to the portal.
profiles.role is student (everyone so far), business_owner, business_staff or admin (migration 39, supabase/migrations/20261101000000_business_portal.sql). Nobody can change their own role: a trigger keeps it, and only the sign-up trigger and grant_role() (database owner only) set it. Make someone an admin from the SQL editor with select public.grant_role('<profile id>', 'admin');. On the live project, staff owners and admins from the admin section become admins here too.admin_set_verified(); the database refuses the mark without it, and anything but approval clears it). "Sponsored" only for a listing or deal with a paid promotion running now: an admin records it on the Review page once it is paid, with the period, the amount and the invoice or receipt reference (admin_record_promotion(); nothing else can create one, and payment itself happens outside UniBuzz). Students read the labels through the listing_sponsored and deal_sponsored computed fields; a business that is neither shows a plain "Business".apartments rows with business_id set, owned by the business's owner whoever on the team posts them. They appear in Explore › Housing in the app and on the website with the business's name and logo, its label and no student profile behind them.Built on the v1 rules above, which all still hold (business-portal.test.mjs runs them on the new schema).
The portal's sidebar (components/business/portal-sidebar.tsx). The business's name and photo at the top open its public profile preview. Graphite, with Overview, Inbox, Advertisements,
Products & Services, Analytics and Billing in that order, and Settings apart at the bottom above the account menu
(appearance, log out). portalSectionsFor() decides what a member sees: an analyst has no Inbox, an editor no
Billing, a pending business an Inbox that says it opens on approval, a rejected one Overview and Settings. On tablets and phones it is a drawer
behind the top bar's menu button.
inbox/layout.tsx, components/business/inquiry-list.tsx), Instagram style:
inquiry-context.tsx: the listing, post or ad as it is
now, with its photo and price, linked to its page in the portal);message-alerts.tsx). Desktop notifications are off until
turned on in Settings › Account or with the bell in the Inbox, per browser.inquiryKindOf, inquiryNeedsReply, INQUIRY_FILTERS, inquiryMatches) are in
packages/shared/src/business.ts; getInquiryDetails() reads what each chat is about and who wrote last.Checks for v2: supabase/tests/business-teams.test.mjs, business-ads.test.mjs, media-server-fields.test.mjs, business-hardening.test.mjs and production-rollout.test.mjs (PGlite, rollbacks included), and packages/shared/tests/business-ads.test.mjs and video-security.test.mjs.
Checks: supabase/tests/business-portal.test.mjs runs every rule above in PGlite (95 checks), and packages/shared/tests/business.test.mjs the labels, deal timing and targeting, standings and the team inbox's titles. supabase/tests/staging/business-portal.mjs runs the same rules on a real Supabase project through Supabase Auth (real sign-ups and email confirmation, with generated links so no email is sent), the REST API, Realtime (the presence channel, live message events across a suspension) and Storage, then deletes what it made; it refuses the production project:
SUPABASE_ACCESS_TOKEN=… STAGING_SUPABASE_REF=<staging ref> node supabase/tests/staging/business-portal.mjs
The staging project (campconnect-staging) is built like production: this repo's migrations through 36, the two in supabase/applied-outside-repo, then 37 onwards, and the seed.
Presence (migration 40). Testing presence on staging showed that Supabase Realtime checks a private channel's join with a broadcast read, so migration 22's presence policy, which allowed only presence reads, refused every join: "Active now" and the green dots stay empty. Migration 40 (20261102000000_presence_channel_join.sql) allows reading that one topic for either kind, still only for people who show their status (sending stays presence only, so there are no broadcasts to read); supabase/tests/presence-channel-join.test.mjs checks it.
Video is the main attraction, so it gets a real pipeline:
POST /api/video/uploads creates a direct-upload URL at Mux and a media row; the browser (or the app, with Authorization: Bearer <token>) sends the untouched file there in resumable chunks with progress, so a dropped connection resumes instead of restarting. Nothing passes through our server.video_quality: plus) and a poster frame. GET /api/video/{mediaId} checks the provider and updates the row until it is ready; the uploader polls it. Optionally set MUX_WEBHOOK_SECRET and SUPABASE_SERVICE_ROLE_KEY and point a Mux webhook at /api/video/webhooks/mux for push updates.videos (jsonb); a trigger keeps has_video in sync. The feed query ranks has_video first, then newest, and the "Video tours only" filter uses the same column. Both rules live in the database, not in the web page.Environment: MUX_TOKEN_ID and MUX_TOKEN_SECRET (Mux → Settings → Access Tokens, Mux Video read + write) on the server only. The .env.example lists them. Mux bills per minute stored and delivered.
| Decision | Status |
|---|---|
| Web: Next.js (React, TypeScript) | Done: apps/web |
| Mobile later: Expo (React Native) | Ready: the shared package was verified to type-check and bundle inside an Expo SDK 57 app (see Moving to iOS and Android) |
| Backend: Supabase (Postgres, auth, realtime, storage) with no custom server | Done: supabase/migrations, security enforced with row-level security |
| Monorepo from day one with Turborepo | Done: npm workspaces + Turborepo (turbo.json): apps/web, packages/shared, apps/mobile later |
Everything that is not a UI component lives in packages/shared | Done: types, zod validation, all Supabase queries, price/date formatting, constants. The web app only keeps Next.js-specific code (server actions, cookies, cn() for Tailwind) |
Why npm workspaces rather than pnpm: nothing to install for a new contributor, Vercel and Expo support it out of the box, and Turborepo provides the task pipeline and caching either way. Switching to pnpm later is a matter of adding pnpm-workspace.yaml and re-installing.
| Area | Features |
|---|---|
| Accounts | Sign-up restricted to verified university emails (@txstate.edu), enforced in the form and by a database trigger; confirmation email; Google sign-in (optional, limited to the same domain); profile with photo, university (attached automatically from the email), program, bio; "Verified student" badge |
| Social | Following, like Instagram: a Follow button on every other person's profile ("Following" once you follow, "Follow back" when they follow you), follower / following counts (public, like the profiles) with your own two lists behind them and, on anyone else's profile, the mutual friends you share with them (row-level security keeps every follow readable only by the two people in it), a "Following" filter on Home → Posts (in the app, a row of the feed dropdown in the top bar), and a notification when someone follows you. Blocking someone removes the follow in both directions and stops new ones; row-level security only lets you add or remove your own follows |
| Profiles | TikTok style (see Profile in the feature map): @username, Following · Followers · Likes, Edit profile / Share profile with a QR code, classes per semester, the person's listings, and Posts, Classes, Reels, Saved and Liked tabs; each person chooses who can see their Classes (Friends by default), Saved (Only me) and Liked (Everyone); up to three pinned posts |
| Messages | Conversations with participants (a group is simply a conversation with 3+ people): 1:1 chats, group chats (create, rename, add people, leave), course chats (one per section of a class each semester, see Profile above: they fill themselves from the class lists, cannot be renamed or hand-filled, and are deleted when the semester ends after a week's notice), Supabase Realtime delivery, unread badges, "Mark all as read" (a pill above the app's inbox while Unread is chosen, a button in the website's chat list: one call, mark_conversations_read() in migration 27, marks every unread chat on screen read, the counts and the badge clearing at once; a chat with nothing new is left alone, as when it is opened), muting (a person's messages everywhere, one group, or every group chat, for 1, 8 or 24 hours or until unmuted: the message and the unread count still arrive, the notice does not; migration 26), rows that show full names only, photo messages, voice messages (recorded in the app with the microphone that takes Send's place while nothing is typed, up to five minutes, played back in a waveform bubble at 1×, 1.5× or 2×; on the website with the browser's player; migration 29), replies (in the app, swipe a message to the right to answer it, in direct and group chats alike; the reply quotes the original above its bubble and a tap on the quote jumps to it; the database builds the quote from the original itself, in migration 31, so nothing can be quoted that was never said; the website shows the quotes), red unread counts on the app's Messages tab and on its Chats and Groups labels, Snaps (a photo or video each person opens once, see ), posts, reels and listings shared from their Share button (as cards that open them), "Message" buttons that pre-fill a first message about the listing, and "Active now" or "Active 5 min ago" under the other person's name in a direct chat (anyone can hide theirs in Settings, see Activity status in the ). A table is ready for the mobile apps |
Security is enforced in the database with row-level security: users can only edit their own listings, only conversation members can read or send messages, and images can only be uploaded to a user's own folder.
apartment-book/
├─ apps/
│ ├─ web/ # Next.js website (App Router, src/ layout)
│ │ ├─ src/app/ # routes: (app)/ has the main site, (auth)/ login + signup
│ │ ├─ src/components/ # UI: layout, home (Orbit, Buzz, Posts, Reels), housing (the Housing | Roommate | Marketplace bar and swipe), apartments, roommates, marketplace, messages, profile
│ │ ├─ src/lib/actions/ # server actions (create/edit/delete, auth, chat)
│ │ ├─ src/lib/supabase/ # Supabase clients for browser and server
│ │ ├─ src/proxy.ts # auth guard: refreshes sessions, protects private routes
│ │ └─ e2e/ # home-feed.mjs: Playwright check of the navigation, Home, Housing and the profile page on a desktop and a phone viewport
│ └─ mobile/ # Expo app for iOS and Android (see apps/mobile/README.md)
│ ├─ src/app/(tabs)/ # bottom tabs: index.tsx (Home), discover.tsx (search, and what is popular on every campus), messages, housing.tsx (the Explore tab: Housing | Roommate | Marketplace), profile; create starts from the "+" on your photo in Home's top bar, and the bell (notifications.tsx) sits in its top-right corner
│ ├─ src/components/ # UI: home (the Home pager), housing (the Housing pager), profile (the TikTok-style profile), post cards, carousels, forms…
│ └─ e2e/ # Maestro flows (flows/*.yaml), sim-seed.mjs test data, run.sh
├─ packages/
│ └─ shared/ # platform-agnostic code reused by the website and the app
│ ├─ src/
│ │ ├─ types/ # Database types + models
│ │ ├─ schemas/ # zod validation for every form
│ │ ├─ queries/ # all reads/writes (apartments, items, roommates, messages, feed, follows, buzz, for-you, storage…)
│ │ ├─ for-you.ts # blendForYou: the fixed pattern behind Home → Orbit
│ │ └─ constants.ts # categories, amenities, currencies, HOME_SECTIONS, HOUSING_SECTIONS…
│ └─ tests/ # unit tests (npm run test:shared)
├─ supabase/
│ ├─ migrations/ # database schema, policies and functions, one file per change (first: 20260906000000_init.sql)
│ ├─ tests/ # offline database tests in PGlite (npm run db:test)
│ └─ seed.sql # starter list of universities
├─ scripts/ # supabase-setup.mjs (one-shot setup), e2e-check.mjs and e2e-home-feed.mjs (live backend checks)
├─ turbo.json # Turborepo task pipeline (typecheck -> lint -> build, with caching)
└─ package.json # npm workspaces root
Rules for packages/shared: no imports from next/*, react-dom or browser globals (window, document, localStorage); every query takes the Supabase client as its first argument; keep types/database.ts in sync with the migration. The web app's ESLint and type-check run through Turborepo (npm run check); the shared package's unit tests run with npm run test:shared (plain node --test on Node 22.18+, no network).
Requirements: Node.js 22.18 or newer (the shared unit tests load TypeScript files directly) and npm.
npm install
The Supabase project for this app (dskbzoqreandwwpxiplh, region us-west-2) is already set up: the migrations and the universities (with campus coordinates and the txstate.edu domain) are loaded, apps/web/.env.local is written, and the auth URLs point at http://localhost:3000. Sign-ups therefore require a @txstate.edu address right now. The steps below are for reference, for a second environment, or if you start a fresh project.
Each new migration has to reach the live project before the code that uses it: migrations 14, supabase/migrations/20260923000000_follows.sql (the follows table, the follow_stats and following_posts functions and the follow notification), 15, supabase/migrations/20260924000000_comment_threads.sql (post_comments.parent_id and score, the post_comment_votes table and the post_comment_vote() function), 16, supabase/migrations/20260925000000_comment_likes.sql (post_comments.likes and the post_comment_vote() function returning the score, the likes and your vote), 17, supabase/migrations/20260926000000_shared_posts.sql (messages.shared_post and the list_friends() function), 18, supabase/migrations/20260927000000_profile_page.sql (usernames, the who-can-see settings, the profile_classes table, likes readable only through functions, the profile_stats and post_view_counts functions and feed_posts.pinned_at), 21, supabase/migrations/20261010000000_private_follow_lists.sql (follows readable only by the two people in them, follow_stats giving everyone the same totals, the mutual_friends() function and are_friends() closed to the API), and 22, supabase/migrations/20261011000000_activity_status.sql (the private user_activity table, the touch_activity(), activity_status(), blocked_either_way_ids() and set_show_active_status() functions, the Realtime Authorization policies of the private presence:active channel, profiles.last_seen_at emptied and no longer written, mark_delivered_all() doing nothing while you hide your status, mark_conversation_read() doing nothing when there is nothing new, messages dated no later than when they are sent, chat members limited to their receipts and a group's name, notifications changed only once shown, and profiles and notifications granted to the API column by column), must be applied with SUPABASE_ACCESS_TOKEN=sbp_xxx npm run db:setup before this version of the website or the app is deployed, or every profile page, Home → Posts → Following, every comment thread, the share sheet and sending a share will fail (only messages that carry a share write shared_post, so plain chat keeps working). Without migration 18 a profile still shows its header, with zeros, and each tab says what it could not load, but searching people by name fails (the search also matches @usernames) and posts lose their "Liked by" line and newest comment. Without migration 21 other people's follower lists cannot load (on someone else's profile the counts open your mutual friends, which come from a function only migration 21 adds), and anyone with the public API key can still read who follows whom. Ship this version right after applying it, though: app builds and website versions from before it read other people's lists straight from the table, so once migration 21 is live they show empty lists (or only you) under the real counts until they get this code. Without migration 22 the private presence channel refuses every join and and do not exist, so no green dots show anywhere (avatars, the app's online friends row, Roommates' Online now) and chat headers show no activity status (the website's says "Direct message", as before); nothing wrong is shown and messaging keeps working. From migration 22 on, and are granted to the API column by column (so their system columns stay closed): a migration that adds a column to either must also , or the API cannot read it (and on that table fails until it does), and must never grant SELECT on the whole table again. Once it is live, older builds and website versions keep working but meet on the old public channel, so they and this version no longer see each other online, and they lose last-seen times ("Active 5m ago" read , now always empty); this version still shows their last-active times to the people they have written to, because their heartbeat () now stamps the private table. Their Settings can still hide the activity status but no longer turn it back on (only , which this version calls, can); the rest of such a save is kept. Right after , redeploy the website and publish the app's JavaScript to installed builds with (a build downloads it on its next launch and runs it from the launch after). Migration 24, , adds the section, the meeting times and the room on classes, the table and triggers behind the course chats, and the daily that warns members a week before a semester ends and deletes its course chats the day after; it schedules itself with pg_cron where that extension is enabled (Supabase dashboard: Database → Extensions), otherwise run once a day another way. Migration 25, , lets that function take the day it runs for and one term (), which is how exercises both the week-before notice and the deletion in PGlite, and moves the leave-the-chat step to an AFTER trigger so a changed section or a deleted chat never collides with the class row it is updating. When a semester ends, the class rows stay on the profile as that semester's history with no chat attached, and the new semester's list starts empty. Migration 28, , adds Snaps (the private bucket, the , and tables, , the "snap" notification, the , , , and functions, and every 10 minutes with pg_cron); it turns on pg_net, and (token mode) then keeps the project URL and service key in Vault so can delete files through the Storage API. Until it is applied the app's Snap screen opens but sending says Snaps aren't set up yet. Migration 29, , adds voice messages (the attachment kind, the bucket's audio types, "Sent a voice message") and the blurred preview and shape on a Snap's message ( takes an eighth, optional argument; and a Snap's going take the preview off); without it voice messages fail to send and Snaps go without a preview (the app falls back to the seven-argument call). Migration 30, , makes delete the file as soon as the last recipient closes it; without it the purge job deletes it within ten minutes instead. Migration 31, , adds replies (, the snapshot of the message answered, built by a trigger from the original); without it sending a reply fails (plain messages keep working). Migration 32, , has leave a Snap's blurred preview on its message, so opened Snaps keep showing it; without it an opened Snap shows its status on a plain dark surface. Migration 33, , adds the Snap activity log (, taking what the viewer's device can detect, , , , the "snap_activity" notification, and saying why a Snap goes); without it the app opens Snaps the old way ( with one argument, claiming nothing about detection), its delivered and capture reports go nowhere, Delete Snap says it is not set up yet, and nothing shows under the bubbles. Both migrations end with , so PostgREST answers the new signatures the moment they land. Migration 34, , corrects migration 33's trigger so a Snap that was viewed and closed logs nothing more (33 alone wrote "Snap deleted" after "Opened"); apply it with 33. Migrations 31 to 34 went live on 2026-10-23. Migration 37, (, Liked private by default, , the Notifications badge without messages), needs no app update first: before it the apps read Listings as private and fall back to . On its first run it also turns every Liked tab still on the old default (Everyone) private. Four migrations reached the live project from outside this repo on 2026-10-25, CampConnect Admin's (staff access and moderation at 15:11 UTC, its dashboard and a privilege hardening at 22:31 UTC); copies, a security review and the plan for bringing them into are in . Until that is done, never run : the CLI's own history on the live project is empty, so it would run every migration again. (needs ) runs a Snap's whole life between two throwaway accounts on the live project, through the same API the apps use, and removes them after. Migrations 41, , and 42, , bring Trust & Safety and Buzz v2 (see ). They are ; until they are, Buzz says it isn't available and posting is refused. They work with CampConnect Admin's migrations (staff list, two-factor rule, hide and restore, reports, suspensions; see ), checked on production's schema by .
Create a personal access token at https://supabase.com/dashboard/account/tokens, then:
SUPABASE_ACCESS_TOKEN=sbp_xxx npm run db:setup # migration + seed + .env.local + auth URLs
SUPABASE_ACCESS_TOKEN=sbp_xxx SUPABASE_PROJECT_REF=<ref> npm run db:check # live end-to-end check
db:setup applies any migration that has not been applied yet and re-runs the seed as an upsert, so it is safe to re-run (for example with SITE_URL=https://yourdomain.com after you go live). db:check creates two temporary users, exercises listings, chat (including realtime), and photo uploads, then deletes them. Delete the access token from the dashboard when you are done with it.
supabase/migrations/ in order (20260906000000_init.sql, then 20260907000000_geo_push_domains.sql). They create all tables, security policies, chat functions, PostGIS columns, the uploads image bucket, realtime for chat, push tokens and the university-email rule.supabase/seed.sql the same way to load the universities with campus coordinates. Texas State University carries the txstate.edu email domain; to open the app to another university, set email_domain on its row (SQL: update universities set email_domain = 'utexas.edu' where name = 'University of Texas at Austin').http://localhost:3000 for now (change to your domain later).http://localhost:3000/auth/callback (and later https://yourdomain.com/auth/callback).{{ .SiteURL }}/auth/callback?token_hash={{ .TokenHash }}&type=signup&next=/settings/profile?welcome=1
so confirmation links work from any device or browser.cp apps/web/.env.example apps/web/.env.local
Fill in the values from Supabase → Project Settings → API:
NEXT_PUBLIC_SUPABASE_URL=https://xxxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ... (the anon / publishable key, never the service_role key)
NEXT_PUBLIC_SITE_URL=http://localhost:3000
Posting on Buzz also needs the server-only SUPABASE_SERVICE_ROLE_KEY, and MODERATION_AI_API_KEY is optional; see Buzz (anonymous).
npm run dev
Open http://localhost:3000, create an account, set your university in Settings, and make a post (or list something on Explore). Open a second browser (or a private window) with another account to try messaging: messages arrive instantly without refreshing.
Other scripts (all run through Turborepo): npm run build, npm run lint, npm run typecheck, and npm run check which runs all three with caching.
The website is designed for Vercel (the company behind Next.js; free Hobby plan is enough to start) with Supabase as the backend.
The site is live at https://apartment-book-vyass.vercel.app (Vercel project apartment-book in the vyass team of the yubrajbajagain2024-2020 account, Root Directory apps/web, production environment variables set, Deployment Protection turned off so the public can reach it). Supabase's auth Site URL points at that address. To ship a new version from this machine:
git push # keeps GitHub in sync (yubrajbajagain2024-byte/Apartment-Book)
vercel deploy --prod --yes --archive=tgz # from the repository root
To deploy automatically on every push instead, connect the GitHub repository in the Vercel dashboard (Project → Settings → Git). The .vercelignore file keeps build caches out of CLI uploads. Run each new file in supabase/migrations/ on the live project (or npm run db:setup) before deploying code that depends on it.
git init
git add .
git commit -m "Apartment Book"
# create an empty repo on github.com, then:
git remote add origin https://github.com/<you>/apartment-book.git
git push -u origin main
apps/web (Vercel detects the npm workspace automatically)..env.local, but with NEXT_PUBLIC_SITE_URL set to your real address (for now the https://<project>.vercel.app URL Vercel gives you).main redeploys automatically.https://<project>.vercel.app/auth/callback to Redirect URLs.Alternative without GitHub: npm i -g vercel, then run vercel inside apps/web and follow the prompts.
yourbrand.com costs roughly USD 10–15 per year.yourbrand.com and www.yourbrand.com.A record pointing to Vercel's IP for the root domain and a CNAME for www). Add them in the registrar's DNS panel; the site is live on your domain once DNS propagates (minutes to a few hours). HTTPS certificates are automatic.NEXT_PUBLIC_SITE_URL in Vercel to https://yourbrand.com and redeploy.https://yourbrand.com, add https://yourbrand.com/auth/callback to Redirect URLs.The business portal on its own subdomain (business.yourbrand.com): add that domain to the same Vercel project (Settings → Domains, then the CNAME record Vercel shows), set NEXT_PUBLIC_BUSINESS_SITE_URL=https://business.yourbrand.com in Vercel and redeploy, and add https://business.yourbrand.com/auth/callback to Supabase's Redirect URLs (BUSINESS_SITE_URL=https://business.yourbrand.com npm run db:setup does that with the other auth settings). The proxy (apps/web/src/proxy.ts) serves the portal at that host's root and the student pages not at all; until then the portal works at /business on the main address.
universities table.NEXT_PUBLIC_MAP_TILE_URL to a MapTiler or Stadia tile URL with your key (both have free tiers).service_role key in the app or in a NEXT_PUBLIC_ variable. The website's pages use only the anon key, and row-level security does the rest. The service key's one server-side use is Buzz's moderated write API (SUPABASE_SERVICE_ROLE_KEY, read only on the server).Nothing in the backend has to change, and the shared package already works in React Native: it was verified by creating an Expo SDK 57 app inside a copy of this monorepo, importing listApartments, apartmentSchema, formatPrice and timeAgo from @apartment-book/shared, type-checking it, and producing a Metro/Hermes bundle. The steps:
npx create-expo-app@latest apps/mobile --template blank-typescript --no-install
apps/mobile/package.json under dependencies, then run npm install from the repository root:
"@apartment-book/shared": "0.1.0",
"@supabase/supabase-js": "^2.115.0"
Expo configures Metro for monorepos automatically (SDK 52 and newer). Next.js and Expo both use React 19.2; if npm ever ends up with two React versions (symptom: "Invalid hook call"), pin the same react version in both apps.apps/web/src/lib/supabase/client.ts, storing the session with expo-secure-store (see the Supabase Expo guide), and add the URL and anon key to app.json extra or an .env file.import { listApartments, formatPrice, type ApartmentWithOwner } from "@apartment-book/shared";
const page = await listApartments(supabase, { universityId, sort: "newest" });
Chat uses the same supabase.channel(...).on("postgres_changes", …) subscription as apps/web/src/components/messages/chat-window.tsx. Three rules learned the hard way, all encoded in apps/web/src/lib/supabase/client.ts: set the user's token on the realtime socket before a channel joins (a channel that joins before the session is loaded stays anonymous and row-level security hides every event), give every subscription a fresh channel name, and pass postgres_changes_options: { wait: true } so SUBSCRIBED means the server is actually streaming. Photo uploads call uploadImage with an ArrayBuffer read from the picked image (await fetch(asset.uri).then((r) => r.arrayBuffer()))."dev": "expo start" and "typecheck": "tsc --noEmit" scripts so Turborepo picks the app up, then ship with EAS Build. You will need an Apple Developer account (USD 99/year) and a Google Play developer account (USD 25 once). Push notifications for new messages can be added with Expo Notifications plus a small Supabase Edge Function triggered on message insert.Until then, the site already works as an installable web app: on a phone, "Add to Home Screen" gives it an icon and a full-screen experience.
packages/shared/src/types/database.ts is written by hand to match the migration. If you change the schema, either edit it or generate it with the Supabase CLI:
npx supabase login
npx supabase gen types typescript --project-id <your-project-ref> > packages/shared/src/types/database.ts
The social network first: pages for clubs and organizations, campus events, campus news, calls switched on, and more campuses. For the Explore tools: saved searches with alerts and ratings for sellers.
joinClassCodepackages/shared/src/profile.tsupdateProfileClasssupabase/migrations/20260927000000_profile_page.sqlprofiles.usernameprofile_classesfeed_posts.pinned_atprofile_statspost_view_countsprofile_section_accessprofile_saved_itemsprofile_liked_itemspost_like_count()post_likers_many()packages/shared/src/profile.tspackages/shared/src/queries/profile-page.ts| Comments | Under every post, reel and apartment (roommate posts and Marketplace items have none: their posters are reached in private, through the Inbox button, in the app and on the website alike; hasPublicComments() in packages/shared/src/share.ts), TikTok style: a "N comments" header with a Top / Newest sort (COMMENT_SORTS and threadComments(…, { sort }) in packages/shared: Top puts the most-hearted first, replies always stay oldest first), each comment as avatar, name (the post owner's carry an "Author" tag), text, time and Reply, with a heart and its count and a thumbs-down on the right (hearts are counted; a dislike stays private and only moves the score); threaded replies (answer a comment and it nests under it, folded behind "View N replies"; the indent stops at three levels, COMMENT_MAX_DEPTH); hold a comment for Reply, Report, Block or Delete (your own comment or any comment on your post; its replies go with it); and a notification for the author of the comment you answered ("… replied to your comment"; the post owner keeps the usual "commented on your post", one message when they are the same person). Migration 15 (supabase/migrations/20260924000000_comment_threads.sql) adds post_comments.parent_id and score (the sum of the votes, kept by trigger), the post_comment_votes table (readable like post_likes, written only through the post_comment_vote() function: 1 up, -1 down, 0 clears) and the reply notification; migration 16 (supabase/migrations/20260925000000_comment_likes.sql) adds post_comments.likes (how many people hearted it, kept by trigger) and has post_comment_vote() return the score, the likes and your vote |
| Sharing | In the app, the Share button on posts and reels and the Share item in every listing's ••• menu (apartments, roommates, marketplace) open a sheet of your friends; on the website, the Share button on posts and reels (video tours of apartments and roommates included) and on apartment cards that carry a video does, while the ••• "Share post" item still copies the link or opens the system share. The sheet is Instagram style: the people you follow who follow you back (blocked people never show), with a search box. Pick one or more, add an optional note and Send: each friend gets a direct message carrying the post as a card (its picture, author, caption or title, and "View post" / "View reel" / "View listing"; in the app a post with a picture is a photo bubble the size of a sent photo, the author and the words on a shade along its bottom) that opens it, with the note as a normal bubble under it. The sheet also offers "Copy link" on the website, plus "Share to…" (the system share sheet) in the app and in phone browsers that support it. The card's link is always rebuilt from the shared thing's type and id, and the database refuses any other link, so a hand-made message cannot turn a card into an outside link. Migration 17 (supabase/migrations/20260926000000_shared_posts.sql) adds messages.shared_post (a snapshot of what was shared), lets a message carrying a share have no words (the inbox and the notification then read "Sent a post", "Sent a reel" or "Sent a listing") and adds the list_friends() function; the snapshots are built in packages/shared/src/share.ts |
| Messaging | Supabase Realtime over conversations, conversation_members, messages. Device push tokens stored in device_push_tokens for the future apps. The app's inbox has two pages, Chats and Groups, side by side like Home's (a swipe or a tap on a label, with the same knock), and All |
| Calls | Audio and video calls in every chat, direct and group (class groups included), from the app (see Calls). The database keeps the call (calls, call_participants, migration 35) and writes its row into the chat (messages.call, following the call live: "Calling…", "In progress", "Audio call · 3:24", "Missed video call"); the sound and picture travel over LiveKit, with a token minted by the livekit-token Edge Function only for someone the database has in the call. The website shows the call rows and says calls are made from the app |
| Activity status | Instagram style, the same in the app and on the website: under the other person's name at the top of a direct chat (the Messages page and the desktop chat dock on the website), a small green dot and "Active now" while they are online, otherwise when they were last active ("Active 1 min ago", "Active 3 hours ago", "Active yesterday" for the day before today, "Active 2 days ago", redrawn every 30 seconds and asked again every minute while the chat is open, and whenever they send a message), or no status at all when there is nothing to show (the website's chat header then says "Direct message", as before). Group chats keep "N members". Who is online comes from Supabase Realtime Presence on a private channel, presence:active (PRESENCE_TOPIC), that only signed-in people who show their activity status may join (the green dots on avatars, the app's online friends row and Roommates' Online now come from it too, leaving out people blocked either way, from blocked_either_way_ids()). The app going to the background stamps the time and stops appearing there at once (no timer runs in the background on a phone, so a grace would last until the server dropped the silent connection); a hidden browser tab does the same 15 seconds later on a computer (a quick look at another tab changes nothing for anyone), and at once on a phone or tablet or when the browser freezes the page. Back in front, they stamp first and appear once that stamp confirms the status is still shown; after a dropped connection that outlasted the last stamp they stamp again before appearing; in front, the time is stamped and the block list read again every minute. Logging out leaves the channel at once. Exact last-active times live in user_activity, a table the API can neither read nor write: activity_status() answers only while you show your own status (hide yours and you see nobody's), they show theirs and neither of you has blocked the other: for anyone you share a chat with, whether they are recent (so "Active now" can show, as their green dot does anyway), and their time, cut to the minute, only once they have written to you in a chat you are both in, since you joined it (an empty chat, a group someone made with everyone, or what a group said before you joined is not enough). "Active now" needs presence and a recent stamp both, since anyone on the channel could claim presence for someone else. Settings → "Show your activity status" turns it off: it deletes your time, and your devices stop marking messages "Delivered" (people see "Seen" once you read something new). Only that switch turns it back on (set_show_active_status()), so a stale Settings save from an installed old build cannot undo a hide. Migration 22 (supabase/migrations/20261011000000_activity_status.sql) adds the table, the touch_activity(), activity_status(), blocked_either_way_ids() and set_show_active_status() functions and the Realtime Authorization policies of the channel, retires the public profiles.last_seen_at (emptied and never written again; old builds still read it), makes mark_delivered_all() do nothing while you hide your status and mark_conversation_read() change nothing when there is nothing new to read (opening a chat no longer tells its members when you had the app open), dates every message no later than when it is sent (one dated ahead used to stay "new" and keep "Seen" and "Delivered" moving), lets chat members change only their own receipts and a group's name (no moving a membership into another chat, no turning a direct chat into a group), lets a notification be changed (read_at only) or deleted only once it shows, and grants profiles and notifications to the API column by column so their system columns (xmin, xmax, which traced who wrote or locked a row and when, down to anonymous Buzz replies and when they were made) are unreadable. The labels live in packages/shared/src/activity.ts, the presence store and the queries in packages/shared/src/queries/presence.ts |
| Login | @txstate.edu only. The allowed domains live on the universities table (email_domain), the sign-up form validates against them, a before insert trigger on auth.users rejects everything else, and new users are attached to their university automatically |
| Housing (Apartments and Roommates) | One main tab, labelled Explore in the bar, two slidable halves with the Marketplace as a third page after them (see Housing). Postgres tables with PostGIS: campus and listing coordinates, apartments_within(university, radius) for "within 2 miles of campus", distance filled by a trigger. Map view and location picker use Leaflet + OpenStreetMap (free); address search uses Nominatim through /api/geocode |
| Events | The fourth tab (Home, Explore, Messages, Events, Profile). A campus's events from two sources: the university's own calendars (ICS and RSS feeds staff add in the console at /admin/events; the website syncs them, apps/web/src/lib/events-server) and verified clubs, student organizations and departments, which any student can create and staff verify; an unverified organization's events show to its members alone. For you ranks upcoming events with one deterministic formula (rankEvents in packages/shared/src/events.ts: picked interests and the categories someone keeps saving, followed organizations, how soon, how many saved or opened it); All events is time order under day headings; both take the date pills (Upcoming, Today, This week, Weekend), the category pills and the search box. Save, Share (the website's /events/<id>), a registration link, and follow an organization. Migration 61 (20261113000000_events.sql): organizations, organization_members, organization_follows, event_feeds (closed to the API), events, event_saves, event_interests, event_engagement, universities.timezone, the events_console_* functions. See Events |
| Marketplace | Same listing pattern with a category field; the third page of the Explore tab. No payments |
UIBackgroundModes: audioapps/mobile/src/lib/home-scope.ts//?tab=buzz/?tab=posts/?tab=reelsHOME_SECTIONShomeSectionHrefpackages/shared/src/constants.tssupabase/tests/profile-page.test.mjssupabase/tests/private-follow-lists.test.mjsfollow_statsare_friends()list_friendsfollowing_postssupabase/tests/activity-status.test.mjsuser_activitytouch_presenceprofilesprofilesnotificationsset_show_active_status()mark_conversation_read()activity_status()blocked_either_way_ids()profiles.last_seen_atsupabase/tests/snaps.test.mjssend_snap()close_snap()purge_snaps()supabase/tests/calls.test.mjssupabase/tests/push.test.mjsnpm run db:test |
| The website's Home and Housing on a desktop and a phone viewport, the top bar and the phone bottom bar, swipes, following (the Follow button on a profile, Maya's Followers and Following numbers opening your mutual friends instead of her lists, a log-in prompt there signed out, your own Followers and Following lists, Posts → Following) and the comment thread under a post (heart a comment and clear it, reply, the "N comments" header and its sort menu, delete the reply from its options menu) and sharing a post with a friend (the Share dialog offers Leo, whom the seed makes a friend, and not Maya; pick him, add a note, Send, then the card and the note in your chat with him) included, plus the carousel dots sitting above the heart / comment / share row, and the profile page (signed out: Maya's Following · Followers · Likes, @username, tabs, grid, no classes card above the tabs, CS 3358 on her Classes tab, her apartment on her Listings tab and her locked Saved tab; signed in: your own profile, and its Saved setting set to Everyone, seen from a signed-out visitor, and back to Only me) (Playwright) | seed data with SUPABASE_ACCESS_TOKEN=sbp_… node apps/mobile/e2e/sim-seed.mjs, start the site (npm run dev -w web -- -p 3060), then BASE=http://localhost:3060 node apps/web/e2e/home-feed.mjs (add PLAYWRIGHT_CHANNEL=chrome to use the installed Chrome instead of downloading Playwright's browser) |
The app in the iOS Simulator (Maestro; 01-signed-in.yaml also tours your own profile: Leo among your Followers, adds and removes a class, the Saved line, and on Maya's profile the empty Mutual friends list behind her Followers number and her Classes and Listings tabs) | the flows in apps/mobile/e2e/flows/: e2e/run.sh e2e/flows/00-signed-out.yaml from apps/mobile, with the same seeded data (see apps/mobile/README.md) |
20261108000300_buzz_categories.sqlsupabase/tests/buzz-categories.test.mjs20261108000100_buzz_top_reply.sql; the website does not show it yet.#c5f53b on #070908) and nothing else. The tokens are BUZZ_PHOSPHOR. While Home rests on Buzz, your own photo in the header and tab bar is replaced by a neutral icon.buzz_comment), without the author;supabase/tests/campconnect-admin-buzz.test.mjs checks all of it on production's schema.
/dealsstart_business_inquiry()). That opens one chat per student and business, named after the business, with every team member in it (later members join it, removed ones leave it). Businesses can't open chats with anyone, can't be messaged directly, can't be added to groups, can't call, and may write in a chat only after the student has, and only while approved. The portal's Inbox has its own list beside the website's chat window (receipts and real-time delivery included), pointed at the portal's own paths: teammates' replies sit on the business's side under their names, and a chat says "Student inquiry" instead of showing the student's activity (v2's Inbox below).my_business()) and admins.business_listing_stats(), business_deal_stats(), business_overview(), readable by a team in good standing and admins only)./join/<token>, valid 14 days, only for that address, only for an address with no UniBuzz account yet). Signing up from it makes a staff account; the invite is used up and the person joins the team only once they confirm their email (a trigger on Supabase Auth's confirmation), and only if the invite is still valid, the business still in good standing and the inviter still its owner. Staff see every inquiry and post and edit listings and deals; only the owner edits the business profile and the team.Roles (20261107000000_business_teams.sql). A business's team is an owner, admins, editors and analysts (v1's staff became editors). business_can(business, capability) decides for every policy and function:
| Role | What it can do |
|---|---|
| Everyone on the team | view the business, read analytics |
| Owner, admins, editors | publish posts and listings, draft ads, reply to inquiries |
| Owner and admins | run the team, edit the profile, submit, resume and end ads |
Audit log. business_audit_log is append-only and closed to the API.
business_audit(): the UniBuzz team sees everything; a business's owner and admins see their own, with the reviewers shown as "UniBuzz team".Posts (20261107000100_business_ads.sql). The deals table holds every post, and the portal calls them Posts.
image_url still mirrors the first photo for older apps. 30 posts a day per business./deals.Public business profile (/businesses/<id> on the website, a screen in the app).
business), open its website (a Website button, a new tab, no referrer), report it or block it.components/business-profile/profile-view.tsx (BusinessProfileView), renders both this page and the portal's preview.Preview (/preview in the portal). The business's name and photo at the top of the sidebar ("View public profile") open the profile exactly as students see it: the same component in its preview mode, in a window with the student site's look in either portal theme ([data-student-preview] in globals.css), with nothing to tap. Every member of the team may look; before approval it says students can't see it yet.
Hours (migration 57, 20261110000000_business_hours.sql; packages/shared/src/business-hours.ts).
businesses.hours (jsonb, public like the profile): a time zone (one of the seven United States zones in HOURS_TIMEZONES), each weekday's ranges (none is closed; up to 3, for a break; "00:00"–"24:00" is open 24 hours; a close at or before the open runs past midnight) and special hours by date (a holiday or event, named or not, closed or with its own hours), which replace that date's usual hours.hours-editor.tsx): a switch to show them, the time zone, a switch and times for each day, "Copy Monday to weekdays", and holiday and special hours. Saving drops dates before yesterday. The form only offers hours where the database has the column.businesses_hours_check, the same rules as businessHoursSchema); editors, analysts and students cannot change them; the audit log records the change.businessHoursStatus()): Open, Open 24 hours, Closes soon (within the hour), Opens soon, or Closed, with when it next closes or opens ("Closes 2 AM" after a late night, "Opens tomorrow 9 AM", "Opens Sat 10 AM") and today's special hours by name. Back-to-back days join, so open around the clock never "closes at midnight". The status is worked out again every half minute while the profile is open; a tap opens the week (today in bold), the coming holiday hours and the time zone. The app shows the same (apps/mobile/src/components/business-hours.tsx).Ads. An ad has:
Its statuses run draft → pending review → scheduled → active → completed, with paused and rejected (with the reason) on the side.
ad_submit, ad_withdraw, ad_pause, ad_resume, ad_end and the admin_*_ad functions. A trigger keeps the status otherwise.ad_problems() lists what still stops a draft, in the portal's words.Placement rules, in the database.
Fair Housing. fair_housing_flags() screens housing ads for preferences by race, color, national origin, religion, sex, familial status or disability (and source of income, which some cities protect).
Review and payment. There is no payment provider yet. The UniBuzz team approves an ad on the portal's Review › Ads by recording its payment (amount and invoice or receipt reference) in listing_promotions, now the ledger of ad payments.
listing_sponsored/deal_sponsored read the paid, running ads.admin_record_promotion() still works and creates the ad. Every v1 promotion became an ad, its payment linked.Time. ads_sweep() (pg_cron, every 5 minutes):
It tells the team each time. What students see never waits for it: the serving checks the dates itself.
What students see. ads_for_feed(placement, limit, offset) returns running, paid ads of approved businesses for the caller's campus, in a daily rotation that is the same for everyone there.
interleaveAds, ADS_EVERY).business_reports: 20 a day per student, read by the UniBuzz team only, never by the business), blocks the business (business_blocks: its ads and posts disappear and it cannot be messaged) or explains why the ad is there.Analytics. business_events is closed to the API and written by record_business_event(). It keeps impressions and clicks once per student per hour; saves, profile visits and inquiries once per day. The team's own views never count.
business_ad_stats(), business_daily_stats() and business_analytics_overview() return counts and reach (distinct students) only, never who.What students see is what was reviewed (migration 56).
promoted_snapshot).reviewed_screen); otherwise the business copies it into a new draft.Videos (migration 55). A video's processing details, and its id, are written by the database and the website's server, never by a signed-in session. Each person has 30 new videos a day, and a ready video is 10 minutes at most. The Mux webhook accepts only recently signed requests.
Notifications. Types business (reviews, the Verified Partner mark, role changes) and ad (approved, rejected, live, paused by the UniBuzz team, ended, needs new dates). They go to the team; the portal has a bell and a list.
The portal's design. Its own colour world under [data-surface="business"] (globals.css), like Buzz's:
biz-theme cookie;Inside a dark portal the shared components are remapped rather than rewritten. Every page has loading, error and not-found states.
Inquiries. 15 new business chats an hour per student. Inquiry targets are now apartment, deal, roommate, item, business and ad.
messages.reply_todevice_push_tokens| Comments | Under posts, reels and apartments (not roommate posts or Marketplace items, which go to private messages), TikTok style: threaded replies folded behind "View N replies", a heart with its count on every comment (a dislike stays private), Top / Newest sort, hold a comment for Reply / Report / Block / Delete (your own comment or any comment on your post); the author of a comment you answer gets a notification |
| Housing → Apartments | Listings with photos, rent, address, a map pin (address search or click-to-pin), distance to campus computed automatically with PostGIS, beds/baths, amenities, availability, lease length; list and map views; filters (university, "within 1/2/5/10 miles of campus", price, bedrooms, furnished, pets, sort); save; message the owner; mark as rented; edit/delete |
| Housing → Roommates | "I have a room" / "I need a room" posts with budget, move-in date, area (optionally pinned on the map with distance to campus), gender preference, sleep schedule, cleanliness, smoking/pets; filters incl. distance from campus; save; message. Made in the app's create flow (photos → title, description, who can see it and post as → the form → preview), optionally anonymous: the poster's identity is kept apart in the database, and they can show who they are to one person, in that person's chat |
| Events | For you and All events, date and category pills, search, saved events, event pages with Save, Share and Register, organizations with Follow, publishing for verified organizations, the feed sync and the console's Events section (migration 61) |
| Marketplace (the third page of Explore) | Items with photos, price (or free), category, condition, pickup location; filters; save; message the seller; mark as sold; the same create flow, audiences and anonymous posting as roommate posts. No payments on purpose: meet on campus, like Facebook Marketplace |
| Other | Global search across all three sections, saved listings page, public profile pages, "add your university", SEO metadata + sitemap, PWA manifest |
followstouch_activity()activity_status()profilesnotificationsgrant select (that_column) on public.<table> to anon, authenticatedselect *onlineprofiles.last_seen_attouch_presence()set_show_active_status()npm run db:setupcd apps/mobile && npx eas-cli@latest update --channel previewsupabase/migrations/20261017000000_course_groups.sqlcourse_groupscourse_groups_rollover()select public.course_groups_rollover()supabase/migrations/20261018000000_course_groups_rollover_params.sqlcourse_groups_rollover('2099-12-26', 'Fall 2099')supabase/tests/course-groups.test.mjssupabase/migrations/20261020120000_snaps.sqlsnapssnapssnap_recipientssnap_purgesmessages.snaplist_snap_recipients()send_snap()open_snap()close_snap()purge_snaps()purge_snaps()npm run db:setuppurge_snaps()supabase/migrations/20261022000000_voice_and_snap_previews.sqlaudiosend_snap()open_snap()supabase/migrations/20261023000000_snap_close_deletes.sqlclose_snap()supabase/migrations/20261024000000_message_replies.sqlmessages.reply_tosupabase/migrations/20261025000000_snap_previews_stay.sqlopen_snap()supabase/migrations/20261026000000_snap_activity.sqlmessages.snap.activityopen_snap()snap_delivered()report_snap_capture()delete_snap()purge_snaps()open_snap()notify pgrst, 'reload schema'supabase/migrations/20261027000000_snap_close_quiet.sqlsupabase/migrations/20261030000000_profile_privacy.sqlprofiles.listings_visibilityprofile_visible_sections()profile_section_access()supabase/migrationssupabase/applied-outside-repo/README.mdsupabase db pushapps/mobile/e2e/snap-lifecycle.mjsSUPABASE_ACCESS_TOKENsupabase/migrations/20261103000000_trust_safety.sqlsupabase/migrations/20261103000100_buzz_v2.sqlsupabase/tests/campconnect-admin-buzz.test.mjs