Add Milestones 1 & 2: full-stack POS foundation with admin UI
- Node/Express/TypeScript API under /api/v1 with JWT auth (login, refresh, logout, /me) - Prisma schema: vendors, users, roles, products, categories, taxes, transactions - SQLite for local dev; Postgres via docker-compose for production - Full CRUD routes for vendors, users, categories, taxes, products with Zod validation and RBAC - Paginated list endpoints scoped per vendor; refresh token rotation - React/TypeScript admin SPA (Vite): login, protected routing, sidebar layout - Pages: Dashboard, Catalog (tabbed Products/Categories/Taxes), Users, Vendor Settings - Shared UI: Table, Modal, FormField, Btn, PageHeader components - Multi-stage Dockerfile; docker-compose with Postgres healthcheck - Seed script with demo vendor and owner account - INSTRUCTIONS.md, ROADMAP.md, .claude/launch.json for dev server config Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
BIN
server/prisma/dev.db
Normal file
BIN
server/prisma/dev.db
Normal file
Binary file not shown.
125
server/prisma/migrations/20260321035729_init/migration.sql
Normal file
125
server/prisma/migrations/20260321035729_init/migration.sql
Normal file
@@ -0,0 +1,125 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Vendor" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"businessNum" TEXT,
|
||||
"taxSettings" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Role" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"email" TEXT NOT NULL,
|
||||
"passwordHash" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"vendorId" TEXT NOT NULL,
|
||||
"roleId" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "User_vendorId_fkey" FOREIGN KEY ("vendorId") REFERENCES "Vendor" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "User_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "RefreshToken" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"token" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"expiresAt" DATETIME NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Category" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"vendorId" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "Category_vendorId_fkey" FOREIGN KEY ("vendorId") REFERENCES "Vendor" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Tax" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"rate" REAL NOT NULL,
|
||||
"vendorId" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "Tax_vendorId_fkey" FOREIGN KEY ("vendorId") REFERENCES "Vendor" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Product" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"sku" TEXT,
|
||||
"description" TEXT,
|
||||
"price" REAL NOT NULL,
|
||||
"vendorId" TEXT NOT NULL,
|
||||
"categoryId" TEXT,
|
||||
"taxId" TEXT,
|
||||
"tags" TEXT,
|
||||
"version" INTEGER NOT NULL DEFAULT 1,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "Product_vendorId_fkey" FOREIGN KEY ("vendorId") REFERENCES "Vendor" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "Product_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT "Product_taxId_fkey" FOREIGN KEY ("taxId") REFERENCES "Tax" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Transaction" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"idempotencyKey" TEXT NOT NULL,
|
||||
"vendorId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL,
|
||||
"paymentMethod" TEXT NOT NULL,
|
||||
"subtotal" REAL NOT NULL,
|
||||
"taxTotal" REAL NOT NULL,
|
||||
"discountTotal" REAL NOT NULL,
|
||||
"total" REAL NOT NULL,
|
||||
"notes" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "Transaction_vendorId_fkey" FOREIGN KEY ("vendorId") REFERENCES "Vendor" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "Transaction_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TransactionItem" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"transactionId" TEXT NOT NULL,
|
||||
"productId" TEXT NOT NULL,
|
||||
"productName" TEXT NOT NULL,
|
||||
"quantity" INTEGER NOT NULL,
|
||||
"unitPrice" REAL NOT NULL,
|
||||
"taxRate" REAL NOT NULL,
|
||||
"discount" REAL NOT NULL,
|
||||
"total" REAL NOT NULL,
|
||||
CONSTRAINT "TransactionItem_transactionId_fkey" FOREIGN KEY ("transactionId") REFERENCES "Transaction" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "TransactionItem_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Role_name_key" ON "Role"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "RefreshToken_token_key" ON "RefreshToken"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Transaction_idempotencyKey_key" ON "Transaction"("idempotencyKey");
|
||||
3
server/prisma/migrations/migration_lock.toml
Normal file
3
server/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "sqlite"
|
||||
134
server/prisma/schema.prisma
Normal file
134
server/prisma/schema.prisma
Normal file
@@ -0,0 +1,134 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Vendor {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
businessNum String?
|
||||
taxSettings String? // JSON string
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
users User[]
|
||||
categories Category[]
|
||||
products Product[]
|
||||
taxes Tax[]
|
||||
transactions Transaction[]
|
||||
}
|
||||
|
||||
model Role {
|
||||
id String @id @default(cuid())
|
||||
name String @unique // cashier | manager | owner
|
||||
|
||||
users User[]
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
passwordHash String
|
||||
name String
|
||||
vendorId String
|
||||
roleId String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
vendor Vendor @relation(fields: [vendorId], references: [id])
|
||||
role Role @relation(fields: [roleId], references: [id])
|
||||
refreshTokens RefreshToken[]
|
||||
transactions Transaction[]
|
||||
}
|
||||
|
||||
model RefreshToken {
|
||||
id String @id @default(cuid())
|
||||
token String @unique
|
||||
userId String
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model Category {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
vendorId String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
vendor Vendor @relation(fields: [vendorId], references: [id])
|
||||
products Product[]
|
||||
}
|
||||
|
||||
model Tax {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
rate Float
|
||||
vendorId String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
vendor Vendor @relation(fields: [vendorId], references: [id])
|
||||
products Product[]
|
||||
}
|
||||
|
||||
model Product {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
sku String?
|
||||
description String?
|
||||
price Float
|
||||
vendorId String
|
||||
categoryId String?
|
||||
taxId String?
|
||||
tags String? // comma-separated or JSON string
|
||||
version Int @default(1)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
vendor Vendor @relation(fields: [vendorId], references: [id])
|
||||
category Category? @relation(fields: [categoryId], references: [id])
|
||||
tax Tax? @relation(fields: [taxId], references: [id])
|
||||
transactionItems TransactionItem[]
|
||||
}
|
||||
|
||||
model Transaction {
|
||||
id String @id @default(cuid())
|
||||
idempotencyKey String @unique
|
||||
vendorId String
|
||||
userId String
|
||||
status String // pending | completed | failed | refunded
|
||||
paymentMethod String // cash | card
|
||||
subtotal Float
|
||||
taxTotal Float
|
||||
discountTotal Float
|
||||
total Float
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
vendor Vendor @relation(fields: [vendorId], references: [id])
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
items TransactionItem[]
|
||||
}
|
||||
|
||||
model TransactionItem {
|
||||
id String @id @default(cuid())
|
||||
transactionId String
|
||||
productId String
|
||||
productName String
|
||||
quantity Int
|
||||
unitPrice Float
|
||||
taxRate Float
|
||||
discount Float
|
||||
total Float
|
||||
|
||||
transaction Transaction @relation(fields: [transactionId], references: [id])
|
||||
product Product @relation(fields: [productId], references: [id])
|
||||
}
|
||||
53
server/prisma/seed.ts
Normal file
53
server/prisma/seed.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
// Seed roles
|
||||
const ownerRole = await prisma.role.upsert({
|
||||
where: { name: "owner" },
|
||||
update: {},
|
||||
create: { name: "owner" },
|
||||
});
|
||||
await prisma.role.upsert({
|
||||
where: { name: "manager" },
|
||||
update: {},
|
||||
create: { name: "manager" },
|
||||
});
|
||||
await prisma.role.upsert({
|
||||
where: { name: "cashier" },
|
||||
update: {},
|
||||
create: { name: "cashier" },
|
||||
});
|
||||
|
||||
// Seed demo vendor
|
||||
const vendor = await prisma.vendor.upsert({
|
||||
where: { id: "demo-vendor" },
|
||||
update: {},
|
||||
create: {
|
||||
id: "demo-vendor",
|
||||
name: "Demo Store",
|
||||
businessNum: "123-456",
|
||||
},
|
||||
});
|
||||
|
||||
// Seed demo owner user
|
||||
await prisma.user.upsert({
|
||||
where: { email: "admin@demo.com" },
|
||||
update: {},
|
||||
create: {
|
||||
email: "admin@demo.com",
|
||||
passwordHash: await bcrypt.hash("password123", 10),
|
||||
name: "Demo Admin",
|
||||
vendorId: vendor.id,
|
||||
roleId: ownerRole.id,
|
||||
},
|
||||
});
|
||||
|
||||
console.log("Seed complete");
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(console.error)
|
||||
.finally(() => prisma.$disconnect());
|
||||
Reference in New Issue
Block a user