尚未检测到更新。GitHub 发布版本后会显示在这里。
个人 GitHub 账号下公开仓库的所有者可以登录并验证所有权,自行管理介绍,无需管理员批准。
登录并生成验证文件,在 30 分钟内将其提交到此仓库的默认分支。其他分支或分叉仓库中的文件无效。然后在下方检查文件,即可无需管理员批准直接管理项目。请保留文件以维持管理权限。
如需登录,将前往登录页面。登录后请回到此处,再次点击所选的验证按钮。
在项目文件夹中运行 /nomorevibe verify,即可验证所有权并自行管理介绍。
一分钟完成验证正在加载完整 README。 以下为已保存的摘录。
This document explains what Daisy is today, how the code is structured, how weekly matching works, and how to operate it safely.
Daisy is a Montreal student matching app:
• users onboard with profile + preferences + phone verification,
• receive one weekly match,
• choose interested/decline,
• unlock contact info only on mutual interest.
The project includes:
• the public web app (Next.js),
• server APIs (App Router route handlers),
• weekly matching generation scripts,
• weekly seeding + SMS scripts,
• admin and local operational scripts.查看完整 README ↗This document explains what Daisy is today, how the code is structured, how weekly matching works, and how to operate it safely.
Daisy is a Montreal student matching app:
The project includes:
Key files:
package.jsonprisma/schema.prismasrc/lib/auth.tssrc/lib/db.tssrc/app/**/page.tsx.src/app/api/**/route.ts.src/proxy.ts.scripts/.../matches.md (outside daisy/).Primary file: src/app/onboarding/page.tsx
Flow:
POST /api/signup),POST /api/upload),POST /api/otp/send, POST /api/otp/verify).Important:
src/app/login/page.tsxsrc/app/api/auth/[...nextauth]/route.tssrc/lib/auth.tssrc/proxy.ts (/dashboard, /profile, /preferences, /verify-phone, and /admin via the admin cookie).src/app/dashboard/page.tsxGET /api/matchPOST /api/match/decisionBehavior:
PENDING, MUTUAL),src/app/forgot-password/page.tsxsrc/app/reset-password/page.tsx -> /forgot-passwordPOST /api/auth/forgot-passwordPOST /api/auth/reset-passwordThis flow uses Twilio Verify on the user's verified phone, not email-link reset.
DELETE /api/user/deleteSchema: prisma/schema.prisma
Core models:
User
Match
userAId + userBId, decisions per side, overall status, dropDatesuggestedSpotIdMeetingSpot
BroadcastDedupe
Enums:
MatchDecision: PENDING, INTERESTED, DECLINEDMatchStatus: PENDING, MUTUAL, EXPIRED, DECLINEDScript: scripts/generate-matches-md.js
What it does:
phoneVerified && onboardingComplete),../matches.md,Drop slot of WED,Script: scripts/seed-week-matches-and-broadcast.js
Dry run:
node scripts/seed-week-matches-and-broadcast.js
Live run:
DAISY_BROADCAST_CONFIRM=yes node scripts/seed-week-matches-and-broadcast.js --seed --sms
Safety behavior (current implementation):
PENDING/MUTUAL -> EXPIRED),dropDate per pair from Drop slot (WED, FRI, SUN),dropDate <= now) and only to users with phoneVerified, phoneNumber, smsConsent.This script parses pair details from ../matches.md and then creates DB rows.
Implemented in scripts/generate-matches-md.js:
Dawson, Kamil, Vincent, Malik),confessions, @office.),vercel.json.src/app/api/cron/wednesday-broadcast/route.tssrc/lib/wednesdayBroadcastSms.tsBroadcastDedupe table by period key. The seed script writes the same key after its --sms run, so the cron skips that day instead of texting matched users twice.dropDate in the last 48h (not every verified member). Sends only Wednesday 5:00–7:59 PM Toronto; a second cron at 22:30 UTC retries if the first slipped.src/lib/sms.ts and respect smsConsent./admin (src/app/admin/page.tsx) — signups, verification
funnel, breakdowns, referrers, suspect emails, and a profile viewer with
delete. Sign in at /admin/login with ADMIN_API_KEY; the session is a
cookie signed with AUTH_SECRET (src/lib/admin-auth.ts).src/app/api/admin/match/route.tssrc/app/api/admin/notify/route.tsDELETE /api/admin/users/[id]x-admin-key header
matching ADMIN_API_KEY.Useful scripts:
scripts/check-users.js - quick user check scriptscripts/reset-match-between.js - reset/create pair between two usersscripts/broadcast-daisy-wednesday.js - broadcast utilityscripts/send-match-sms-one.js - one-off SMSscripts/wipe-db.js - destructive reset script (matches/users/blob cleanup)Install:
npm install
Run app:
npm run dev
Build:
npm run build
Start production build:
npm run start
Lint:
npm run lint
Database:
DATABASE_URLDIRECT_URLAuth/session:
AUTH_SECRETAUTH_URL / NEXTAUTH_URL / VERCEL_URL (base URL helpers)Twilio:
TWILIO_ACCOUNT_SIDTWILIO_AUTH_TOKENTWILIO_PHONE_NUMBERTWILIO_VERIFY_SERVICE_SIDStripe billing (pay-per-reroll, $1.99 CAD one-time):
STRIPE_SECRET_KEYSTRIPE_WEBHOOK_SECRETSTRIPE_REROLL_PRICE_ID (optional — a real Price for cleaner reporting; if
unset, checkout builds the line item inline from REROLL_PRICE_CENTS)Broadcast/cron:
CRON_SECRETBROADCAST_SITE_URLNEXT_PUBLIC_URLBROADCAST_SUPPORT_EMAILBROADCAST_BODYBROADCAST_REQUIRE_RECENT_DROPDAISY_BROADCAST_CONFIRM (script safety)Admin:
ADMIN_API_KEYOther:
NEXT_PUBLIC_GA_IDBLOB_READ_WRITE_TOKEN (for blob cleanup script)matches.md is the review layer before DB writes.POST /api/reroll/checkout opens a $1.99 CAD Stripe Checkout in payment mode; POST /api/reroll spends the resulting credit, closes the current match as REROLLED (if it was still PENDING), and opens a new one via src/lib/matching.ts. A match that closed this week (declined by either side, or rerolled away) can also be rerolled, so "Not for me" doesn't lose the upsell. The person rerolled away from gets a free re-match when the pool allows, and everyone who receives a new match is texted.smsConsent is set from the checkbox on the phone step (POST /api/otp/verify), not assumed at signup, and can be turned off on the profile page.POST /api/stripe/webhook and is required to grant reroll credits. It must be subscribed to checkout.session.completed and checkout.session.async_payment_succeeded.This section tracks the core business metrics and gives a quick way to refresh them.
Run this from daisy/:
node -e "const fs=require('fs'); const path=require('path'); ['.env','.env.local'].forEach(f=>{const p=path.resolve(__dirname,f); if(!fs.existsSync(p)) return; fs.readFileSync(p,'utf8').split('\n').forEach(line=>{const m=line.match(/^\s*([A-Z_][A-Z0-9_]*)=[\"']?(.+?)[\"']?\s*$/); if(m && !process.env[m[1]]) process.env[m[1]]=m[2];});}); const {PrismaClient}=require('@prisma/client'); const prisma=new PrismaClient(); (async()=>{ const totalUsers=await prisma.user.count(); const verified=await prisma.user.count({where:{phoneVerified:true}}); const onboarded=await prisma.user.count({where:{onboardingComplete:true}}); const latest=await prisma.match.findFirst({orderBy:{dropDate:'desc'},select:{dropDate:true}}); let week={matches:0,replied:0,totalPeople:0,interested:0,declined:0,pending:0,mutual:0,waiting:0,replyRate:0,interestRate:0}; if(latest){ const rows=await prisma.match.findMany({where:{dropDate:latest.dropDate},select:{status:true,userADecision:true,userBDecision:true}}); let i=0,d=0,p=0; for(const r of rows){ for(const x of [r.userADecision,r.userBDecision]){ if(x==='INTERESTED') i++; else if(x==='DECLINED') d++; else p++; }} const replied=i+d; const totalPeople=rows.length*2; const mutual=rows.filter(r=>r.status==='MUTUAL').length; const waiting=rows.filter(r=>(r.userADecision==='INTERESTED'&&r.userBDecision==='PENDING')||(r.userBDecision==='INTERESTED'&&r.userADecision==='PENDING')).length; week={matches:rows.length,replied,totalPeople,interested:i,declined:d,pending:p,mutual,waiting,replyRate:totalPeople?Math.round(replied/totalPeople*100):0,interestRate:replied?Math.round(i/replied*100):0,dropDate:latest.dropDate.toISOString()}; } console.log(JSON.stringify({totalUsers,verified,onboarded,week},null,2)); await prisma.$disconnect(); })();"
If wanted, we can add: