Synaplan
开源 AI 软件平台,面向企业:自托管 AI 代理,符合 GDPR 的聊天机器人小部件,RAG 知识库,WhatsApp 与 Outlook AI,MCP,多模型路由。支持 Docker、Kubernetes 或在 Germ 托管。
AI 翻译
尚未检测到更新。GitHub 发布版本后会显示在这里。
个人 GitHub 账号下公开仓库的所有者可以登录并验证所有权,自行管理介绍,无需管理员批准。
登录并生成验证文件,在 30 分钟内将其提交到此仓库的默认分支。其他分支或分叉仓库中的文件无效。然后在下方检查文件,即可无需管理员批准直接管理项目。请保留文件以维持管理权限。
如需登录,将前往登录页面。登录后请回到此处,再次点击所选的验证按钮。
在项目文件夹中运行 /nomorevibe verify,即可验证所有权并自行管理介绍。
一分钟完成验证client app test
翻译准备中
取自仓库 README
正在加载完整 README。 以下为已保存的摘录。
A white-label, multi-tenant sales, loyalty, membership, and customer-engagement platform for clinics and practices in general (not tied to any single specialty). Each tenant gets its own branded customer app and a full business-administration dashboard; a platform-admin layer manages tenants across the whole system.查看完整 README ↗A white-label, multi-tenant sales, loyalty, membership, and customer-engagement platform for clinics and practices in general (not tied to any single specialty). Each tenant gets its own branded customer app and a full business-administration dashboard; a platform-admin layer manages tenants across the whole system.
Three portals, one codebase:
/[tenant]/..., mobile-first) — browse services, products, packages, and promotions; book appointments; join memberships; earn and redeem loyalty points; message the clinic; manage profile and notification preferences./admin/..., desktop-first but responsive) — manage the catalogue, customers, leads, appointments, memberships, loyalty, promotions, notification campaigns, conversations, staff and permissions, locations, branding, business settings, analytics, and the audit log./platform/...) — create and suspend tenants, view platform-wide usage, and manage subscription status per tenant.Demo/seeded data is clearly a fixture for a fictional business ("Riverside Wellness Clinic") — swap it out via prisma/seed.ts for a real deployment.
tenantId column. All staff/customer requests go through getTenantDb(tenantId) (lib/tenant-db.ts), a Prisma Client Extension that automatically injects tenantId into every where/create for tenant-scoped models — so a missing manual filter can't leak another tenant's data. Only platform-admin code paths use the raw, unscoped client (lib/db.ts), and always explicitly by tenant id when looking at a single tenant. Tenant identity for staff/customers comes only from their authenticated session, never from the URL.User table (role: PLATFORM_ADMIN | TENANT_ADMIN | STAFF | CUSTOMER): customers sign in at /[tenant]/login, staff at /admin/login (workspace slug + email + password), platform admins at /platform/login.TENANT_ADMIN has implicit full access. STAFF permissions come from a tenant-scoped, admin-editable Role → RolePermission → Permission model (/admin/staff/roles), checked server-side via requirePermission()/requireRole() (lib/rbac.ts) in every server action and route handler.PaymentProvider interface (lib/providers/payments) with a MockPaymentProvider (default — deterministic, no card data ever stored) and a StripePaymentProvider, switched via PAYMENT_PROVIDER. Checkout math (tax, promo/loyalty discounts, account credit) is pure and unit-tested in lib/checkout-calculations.ts.ChannelProvider interfaces (lib/providers/notifications) — mock by default, with documented integration points for Resend (email), Twilio (SMS), and Web Push. Campaigns are queued through NotificationCampaign/NotificationDelivery regardless of provider.Conversation/Message models support in-app messaging today, with the data model and provider pattern ready for email/SMS/social channels later./public/uploads) by default via a StorageProvider interface, swappable for S3-compatible storage.Next.js 15 · React 19 · TypeScript · Tailwind CSS · PostgreSQL · Prisma ORM · Auth.js v5 · Zod · Vitest.
npm install
npm run setup # starts Docker + PostgreSQL, migrates, seeds demo data
npm run dev # → http://localhost:3000
npm run setup is idempotent and handles everything: it starts Docker Desktop if it isn't running, brings up the database container and waits for it to be healthy, creates .env with a fresh AUTH_SECRET if missing, applies migrations to both the dev and test databases, and seeds demo data only if the database is empty. Run it any time — after a clone, after a reboot, or whenever something looks off.
The manual steps below explain what it does, if you'd rather run them yourself or use your own PostgreSQL.
npm install
Recommended — Docker Compose (creates both the dev and test databases for you):
docker compose up -d
This maps the container's PostgreSQL to host port 5433, deliberately avoiding a clash with any PostgreSQL already installed natively on 5432. .env.example already points at 5433.
Alternative — your own PostgreSQL. Create the role and databases yourself:
CREATE ROLE app_user WITH LOGIN PASSWORD 'app_password' CREATEDB;
CREATE DATABASE app_dev OWNER app_user;
CREATE DATABASE app_test OWNER app_user;
Then change the port in DATABASE_URL (in both .env and .env.test) from 5433 to your instance's port, usually 5432.
cp .env.example .env
Generate a real AUTH_SECRET:
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
See Environment variables below for the full list.
npm run prisma:migrate
npm run seed
Seed output prints the demo login credentials. Everything uses the password Password123!:
Start at /, which links to all three portals. Admin and Clinic share one sign-in at /login; the account's role decides where it lands.
| Portal | Where | |
|---|---|---|
| Admin (agency) | /login → /agency | [email protected] |
| Clinic | /login → /m/:merchantId | [email protected] (staff: [email protected]) |
| Client | /app/riverside-wellness | [email protected] (or any other seeded client) |
npm run dev
Visit http://localhost:3000.
After a machine reboot (or any time Docker was shut down):
npm run setup # brings Docker + the database back, no-ops on anything already fine
npm run dev
The database container is declared restart: unless-stopped, so it comes back on its own once Docker Desktop is running — npm run setup mainly covers the case where Docker itself isn't up yet. Your data lives in a named Docker volume and survives container restarts, docker compose down, and reboots.
| Command | What it does |
|---|---|
npm run setup | Full idempotent bootstrap — start Docker, database, migrate, seed if empty |
npm run setup:reset | Destructive. Wipes the database volume and reseeds from scratch |
npm run db:up / db:down | Start / stop just the database container |
npm run db:logs | Tail PostgreSQL logs |
npm run dev:clean | Clear the .next cache, then start the dev server |
next dev fails with EINVAL: invalid argument, readlink … .next/app-build-manifest.json
This project lives under OneDrive, whose sync filter can corrupt Next.js's build cache. Fix:
npm run dev:clean
failed to connect to the docker API … dockerDesktopLinuxEngine
Docker Desktop isn't running. npm run setup will start it and wait, or start it yourself and re-run.
Port 5433 vs 5432
The container publishes on 5433 on purpose, so it never collides with a PostgreSQL installed natively on 5432. If you'd rather use 5432, change the port mapping in docker-compose.yml and the DATABASE_URL in both .env and .env.test.
Tests fail with "table does not exist"
The test database needs its own migrations: npm run test:setup.
All variables are documented in .env.example. Summary:
| Variable | Purpose |
|---|---|
DATABASE_URL | Postgres connection string |
AUTH_SECRET | Auth.js session signing (leave NEXTAUTH_URL unset; the request host is used) |
PAYMENT_PROVIDER | mock (default) or stripe |
STRIPE_SECRET_KEY, STRIPE_PUBLISHABLE_KEY | Required only when PAYMENT_PROVIDER=stripe |
STRIPE_CONNECT_WEBHOOK_SECRET | Signing secret of the webhook endpoint listening to connected account events (clients' payments). Needed to take money |
STRIPE_WEBHOOK_SECRET | Signing secret of the webhook endpoint listening to platform account events (billing the clinics) |
EMAIL_PROVIDER, RESEND_API_KEY, EMAIL_FROM | mock (default) or resend |
SMS_PROVIDER, TWILIO_* | mock (default) or twilio |
PUSH_PROVIDER, WEB_PUSH_* | mock (default) or webpush |
STORAGE_PROVIDER, S3_* | local (default, writes to /public/uploads) or s3 |
APP_URL | Used for building absolute links (e.g. Stripe billing portal returns) |
npm run prisma:generate # regenerate the Prisma client after a schema change
npm run prisma:migrate # create/apply a migration in development
npm run prisma:deploy # apply migrations in production (no schema drift prompts)
npm run prisma:studio # browse the database visually
npm run seed # (re)seed demo data — safe to re-run, replaces the demo tenant
scripts/prod.ps1 (PowerShell) asks once for the production connection string,
shows which database it is about to touch, waits for confirmation, and clears
the connection string afterwards — nothing is written to disk, so there is no
.env.production.local to remember to delete.
npm run prod:status # read-only: what is pending?
npm run prod:migrate # apply pending migrations
npm run prod:admin -- --email [email protected] # create the first platform admin
npm run prod:reset-admin -- --email [email protected] # reset a platform admin password
npm run prod:apple-pay # register Apple Pay domains on connected accounts
npm run prod:items -- --dry-run # backfill redeemable items (counts first)
npm run prod:client -- --clinic testclinic --email [email protected]
Paste Neon's direct connection string (the host without -pooler): a
migration takes a lock that a pooled connection can fail on.
npm run dev # start the dev server
npm run lint # ESLint
npm run typecheck # tsc --noEmit
npm run build # production build
npm run start # run the production build
Tests run against a separate database (app_test by default, configured in .env.test) so they never touch your dev data.
npm run test:setup # one-time: apply migrations to the test database
npm test # run once
npm run test:watch # watch mode
Re-run npm run test:setup after any schema change.
Test coverage includes: password hashing, RBAC role/permission checks, tenant isolation (cross-tenant reads/writes/deletes are structurally blocked), checkout math (subtotal/tax/discount/credit interactions), the mock payment provider, promotion eligibility (expiry, usage limits, per-customer limits, item-scoped eligibility), loyalty point earning and redemption, manual credit/point adjustments (reason required, auditable), the membership join/pause/resume/cancel state machine, appointment availability (working hours, existing bookings, blocked time), and payment/refund state transitions (including over-refund rejection).
Some tests exercise real server actions with @/lib/rbac's session-dependent functions mocked to a fixed test user/tenant, so business logic runs for real against Postgres without needing a live HTTP session.
npm run build
npm run start
Set NODE_ENV=production and point DATABASE_URL at your production database; run npm run prisma:deploy as part of your deploy pipeline before starting the app.
Default is PAYMENT_PROVIDER=mock — checkout and membership billing work end-to-end with no external account. Set MOCK_PAYMENTS_DECLINE=1 to make every mock payment fail, which is how the failed-payment path is exercised; it is ignored when NODE_ENV=production. No card data is ever collected or stored by this codebase either way.
To enable Stripe:
Set PAYMENT_PROVIDER=stripe, STRIPE_SECRET_KEY, STRIPE_PUBLISHABLE_KEY.
Register two webhook endpoints in Stripe, both pointing at POST /api/webhooks/stripe. Stripe treats "events on your account" and "events on connected accounts" as separate endpoints and will not deliver both to one, and it signs each with that endpoint's own secret. The route tries both secrets and refuses a request only when neither verifies.
| Endpoint | Listen to | Events | Secret goes in |
|---|---|---|---|
| Connected accounts | Events on connected accounts | payment_intent.succeeded, payment_intent.payment_failed, charge.refunded, charge.dispute.created, account.updated, account.application.deauthorized, invoice.paid, invoice.payment_failed, customer.subscription.updated, customer.subscription.deleted | STRIPE_CONNECT_WEBHOOK_SECRET |
| Platform account | Events on your account | the platform's own billing of its clinics, when you set that up | STRIPE_WEBHOOK_SECRET |
Clients' memberships are subscriptions on the clinics' own accounts, so invoice.paid and invoice.payment_failed belong on the connected accounts endpoint — that is what activates a membership and what puts one past due.
Clients' payments are direct charges on the clinic's own connected account, so the Connect endpoint is the one that marks orders paid. An instance that only takes client payments can set just STRIPE_CONNECT_WEBHOOK_SECRET; one that only bills clinics can set just STRIPE_WEBHOOK_SECRET. Each secret is issued per endpoint, so local and production values differ, and stripe listen --forward-connect-to issues its own again.
The customer-facing checkout UI collects payment via Stripe Elements/Checkout on your front end — only a token/PaymentIntent id ever reaches this codebase.
A membership is a Stripe subscription on the clinic's own connected account: the clinic's customer, the clinic's price, the clinic's invoices, visible in the clinic's own Stripe dashboard. The platform's cut rides on each invoice as an application_fee_percent.
PENDING and the subscription with its first invoice incomplete. The client confirms that invoice in the Payment Element. Nothing is a membership until it is paid: no benefits and no included credit while it is PENDING.invoice.paid is the only thing that activates a period. It sets the period dates, clears any outstanding failures, and grants the plan's included credit — once per invoice, so a repeated delivery cannot grant it twice, and again for each new period.Tenant.membershipFeePercent. Blank means the clinic pays the same percentage as its other sales (PLATFORM_FEE_PERCENT); 0 means the platform takes nothing. It is never editable by the clinic.PAST_DUE and stamps pastDueSince. Benefits continue for seven days (PAST_DUE_GRACE_MS in lib/membership-status.ts) while Stripe retries the card; the clock starts at the first failure and is not restarted by later ones. The memberships cron job then suspends it. A payment that succeeds at any point brings it straight back.serviceDiscountPercent and productDiscountPercent become discount candidates in lib/discounts.ts, scoped to treatments and to products respectively, so the member price in the shop and the one charged at checkout come from one calculation.bestDiscount; the client gets the single best one. A membership discount has no promotion behind it, so no PromotionRedemption is written and no usage limit applies — it is a benefit of paying for the plan, available every time.PENDING membership gives nothing; a PAST_DUE one keeps the member price until the grace period runs out; SUSPENDED and CANCELLED give nothing. One helper, membershipGivesBenefits, decides it everywhere.CustomerProfile.accountCreditBalanceCents — the balance the client app already showed them — with a row in the credit ledger, granted per paid period. CustomerMembership.creditBalanceCents keeps the running total that plan has granted, for the clinic's billing history.lib/account-credit.ts is the only place a balance moves: one conditional UPDATE plus a ledger row, like the loyalty ledger, so two checkouts cannot spend the same credit.
useCredit as a yes/no. How much is read from the balance on the server and capped at the bill, so a browser cannot ask for credit the client does not have.Payment row whose provider is ACCOUNT_CREDIT, and refunding that payment puts the credit back rather than touching a bank.Installed from the Home Screen, the client app is held at one scale so it reads as a native app: no pinch-zoom, no double-tap zoom, no lurch when a field is focused. Four things do that, and they only apply under .client-app — the clinic dashboard and the agency panel are desktop tools where zooming is a normal thing to want.
clientAppViewport in lib/client-app-viewport.ts, exported by both client-app routes: initial-scale=1, maximum-scale=1, user-scalable=no, with viewport-fit=cover kept because the safe-area insets (--sat/--sab) are zero without it.user-scalable=no, which Safari has ignored in a browser tab since iOS 10. ZoomLock refuses WebKit's gesturestart/gesturechange/gestureend — events that fire only for a two-finger zoom, so scrolling, sheet swipes and the QR camera are untouched. The listeners must be non-passive or preventDefault does nothing, which a test pins down.touch-action: manipulation on .client-app and on every control inside it. The browser intersects touch-action down the ancestor chain, so the app-level rule covers everything while .grab (none, for dragging a sheet closed) and .cin (pan-y, for swiping a cart row) still narrow it where they need to.input, select and textarea in the app is at least 16px: iOS zooms in on a smaller field and never zooms back out.Taking the zoom away is only defensible if nothing needed zooming into, so tests/client-app-zoom.test.ts also enforces the consequences: nothing in the client app's stylesheet or components sets text below 14px (the Klarna wordmark is exempt — a logo in a tile, not a sentence), and --ink, --ink-strong, --muted and --danger all clear WCAG AA (4.5:1) against every background they are used on. --muted and --danger were darkened to #626a7a and #bd3b40 to reach it; --faint stays as it is and is barred from text, because at 1.9:1 it is a page dot, not a word.
lib/providers/notifications/{email,sms,push}.ts are documented, ready-to-implement integration points (Resend, Twilio, Web Push respectively) — each currently throws with instructions if selected without being implemented. Until then, EMAIL_PROVIDER/SMS_PROVIDER/PUSH_PROVIDER=mock (the default) logs the send and records a normal NotificationDelivery row, so campaign creation, targeting, and delivery-status UI all work today without any external account.
lib/rate-limit.ts) is in-memory and per-process — fine for one instance, needs a shared store (e.g. Redis) behind a load balancer.User table's unique index is [tenantId, email], and platform admins have tenantId = null) — acceptable given platform admins are few and created only by other platform admins.TenantSettings.dataRetentionDays) is stored and surfaced in settings but no background job currently acts on it.appointmentReminderHours) are stored in settings but no scheduler currently sends them — wire up a cron/queue calling the notification campaign path.creditAppliedCents) and in the credit ledger, but giving that part back is a manual adjustment for now. An order paid for entirely with credit refunds entirely to credit./api/cron/marketing and /api/cron/memberships.sortOrder fields already exist; the current admin UI edits them via a plain "Save changes" form field rather than drag handles).