Synaplan
기업을 위한 오픈소스 AI 플랫폼
Business Management System
README 전체를 불러오는 중입니다.
README 전문 보기 ↗Field service management for small trade businesses — clients, leads, scheduling, jobs, estimates, invoicing and payments.
Built as a re-skinnable template: signup asks what kind of business this is and sets the vocabulary to match — Jobs and Estimates for a contractor, Projects and Proposals for an agency, Repair Orders and Customers for a shop. Business name, logo, colors and each of those labels stay editable in Settings, so the same codebase serves different trades without a rebuild.
As a Windows app. npm run desktop:pack produces an installer the customer
double-clicks — no Node, no terminal, no account. Data stays on their machine
and the office PC hosts the team over the local network. See
DESKTOP.md.
As a hosted app. Everything below.
npm install && npm run db:push && npm run db:seed && npm run dev
Then open http://localhost:3000 and sign in.
| Account | Password | Role | What they see |
|---|---|---|---|
[email protected] | demo1234 | Owner | Everything, including settings |
[email protected] | demo1234 | Administrator | Everything except org transfer |
[email protected] | demo1234 | Manager | Operations and billing, no settings write |
[email protected] | demo1234 | Employee | Only their own assigned work, no financials |
Signing in as tech2 is the quickest way to see role-based access working: the
financial dashboard tiles disappear, the sidebar loses six sections, the job
list narrows to their own assignments, the calendar goes read-only, and typing
/invoices into the address bar lands on an explanatory "no access" screen
rather than the data.
| Command | Does |
|---|---|
npm run dev | Dev server on :3000 |
npm run build | Generates the Prisma client, then a production build |
npm test | Runs the test suite once |
npm run desktop:pack | Builds the Windows installer into dist-installer/ |
npm run desktop:build | Assembles the desktop bundle without packaging |
npm run desktop | Runs the desktop app from that bundle |
npm run test:watch | Re-runs affected tests as you edit |
npm run db:push | Applies the schema for the current DATABASE_URL |
npm run schema:sync | Regenerates prisma/schema.sqlite.prisma from the Postgres schema |
npm run schema:check | Fails if that projection is stale or unportable (run it in CI) |
npm run db:seed | Loads demo data (safe to re-run; scoped to the demo org) |
npm run db:reset | Destroys all data, recreates the schema, reseeds |
npm run db:studio | Prisma Studio, a table browser for the local database |
db:reset wipes the database. It is meant for local development only — never
point it at anything real.
Matlock One ships twice from one codebase: hosted on Postgres, and as a desktop install where the business's records are a SQLite file on their own machine.
prisma/schema.prisma is the only schema anyone edits. npm run schema:sync
projects it onto prisma/schema.sqlite.prisma, changing the datasource provider
and the generator output path and nothing else — so a schema change is written
once. npm run schema:check fails if the projection is stale, and also rejects
anything SQLite cannot represent (enums, scalar lists, Json, @db. native
types), which is what keeps the projection honest.
DATABASE_URL decides the rest at runtime. Its scheme selects the driver
adapter in src/lib/db.ts, the schema prisma.config.ts hands the CLI, and
whether searches ask for case-insensitive matching. An unrecognised URL is
rejected at startup rather than guessed at.
Searches go through like() in src/lib/search.ts, never a bare contains.
SQLite's LIKE is case-insensitive for ASCII; Postgres LIKE is not, and needs
mode: "insensitive" — which SQLite in turn rejects. Neither spelling is
portable, so the choice is made in one place instead of at 69 call sites. A bare
contains still compiles and still passes the SQLite tests; it just quietly
half-works in the cloud, which is exactly the kind of bug that reaches a
customer.
Photos and documents go through a three-function seam in src/lib/storage/
(put / get / remove), the same shape as the payment and email seams. Which
store is underneath is decided by the environment:
| Provider | Used by | Direct upload |
|---|---|---|
local | desktop installs, local development | no — and does not need it |
s3 | any S3-compatible store: R2, S3, Backblaze, Supabase, MinIO | yes |
vercel-blob | hosted on Vercel | yes |
Local disk is refused on Vercel, whose filesystem is read-only and belongs to a single invocation. Without that check the failure is silent and delayed: every upload appears to succeed and is a broken link by the next request.
Uploads bypass the server where they can. A serverless function's request
body is capped at 4.5MB — under the 15MB this application allows, and under a
great many phone photos. So the browser asks /api/files/upload-ticket for a
presigned URL, sends the bytes straight to the store, and hands back a signed
ticket naming the key, the organization, the person and the record. The confirm
step re-reads the object's real size from the store rather than believing the
size the client promised, and checks the ticket's organization against the
caller's own. Where a store cannot presign, the form submits the files normally
and nothing about that path changed.
여기까지가 README 앞부분입니다. 나머지는 원문에서 읽을 수 있습니다. README 전문 보기 ↗