Synaplan
Open-source AI platform for companies: self-hosted AI agents, GDPR-compliant chatbot widget, RAG knowledge base, WhatsApp and Outlook AI, MCP, multi-model routing. Docker, Kubernetes or hosted in Germ
Customers, jobs, scheduling, quotes, invoices, payments and expenses in one workspace. Built by Matlock Software Development.
BusinessTypeScript · JavaScriptGitHub @JLMattyiceOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
Business Management System
Translation pending
Loading the full README.
Read the full 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.
This is the beginning of the README. Read the rest at the original. Read the full README ↗