🎉 init: 小龙的工作空间

This commit is contained in:
大海
2026-06-06 10:40:48 +08:00
commit a188ee1426
3201 changed files with 231817 additions and 0 deletions
File diff suppressed because one or more lines are too long
+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
data/
.env
*.db
*.db-journal
*.db-wal
+90
View File
@@ -0,0 +1,90 @@
# NoteApp — Backend API
> 一款支持多端同步、Markdown 编辑和标签管理的笔记应用。
## Tech Stack
- **Runtime**: Node.js
- **Framework**: Fastify 5
- **Language**: TypeScript
- **Database**: SQLite (better-sqlite3)
- **Auth**: JWT + bcrypt
## Getting Started
```bash
# Install dependencies
npm install
# Development (hot reload)
npm run dev
# Build
npm run build
# Production start
npm run start
# Run tests
npm test
```
## Project Structure
```
src/
├── index.ts # Server entry point
├── db/
│ ├── schema.ts # SQLite schema
│ └── client.ts # Database client
├── routes/
│ ├── auth.ts # Auth routes (register/login/me)
│ └── *.ts # CRUD routes
├── services/
│ └── *.ts # Business logic
├── middleware/
│ └── auth.ts # JWT middleware
├── types/
│ └── index.ts # TypeScript types
└── __tests__/
└── *.test.ts # Tests
```
## API Endpoints
### Auth
- `POST /api/auth/register` — Register
- `POST /api/auth/login` — Login
- `GET /api/auth/me` — Current user (auth required)
### Resources
#### customers
- `GET /api/customers` — List all
- `GET /api/customers/:id` — Get by ID
- `POST /api/customers` — Create
- `PUT /api/customers/:id` — Update
- `DELETE /api/customers/:id` — Delete
#### sales_pipeline
- `GET /api/sales_pipeline` — List all
- `GET /api/sales_pipeline/:id` — Get by ID
- `POST /api/sales_pipeline` — Create
- `PUT /api/sales_pipeline/:id` — Update
- `DELETE /api/sales_pipeline/:id` — Delete
#### follow_ups
- `GET /api/follow_ups` — List all
- `GET /api/follow_ups/:id` — Get by ID
- `POST /api/follow_ups` — Create
- `PUT /api/follow_ups/:id` — Update
- `DELETE /api/follow_ups/:id` — Delete
#### dashboard
- `GET /api/dashboard` — List all
- `GET /api/dashboard/:id` — Get by ID
- `POST /api/dashboard` — Create
- `PUT /api/dashboard/:id` — Update
- `DELETE /api/dashboard/:id` — Delete
### System
- `GET /api/health` — Health check
+27
View File
@@ -0,0 +1,27 @@
{
"name": "noteapp",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"test": "node --import tsx --test src/__tests__/*.test.ts",
"test:watch": "node --import tsx --test --watch src/__tests__/*.test.ts"
},
"dependencies": {
"fastify": "^5.0.0",
"@fastify/cors": "^10.0.0",
"@fastify/jwt": "^9.0.0",
"sql.js": "^1.12.0",
"bcrypt": "^5.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/bcrypt": "^5.0.0",
"typescript": "^5.6.0",
"tsx": "^4.0.0",
"pino-pretty": "^11.0.0"
}
}
@@ -0,0 +1,96 @@
// Auth routes test
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { buildApp } from "../index.js";
import type { FastifyInstance } from "fastify";
let app: FastifyInstance;
let token: string;
before(async () => {
process.env.JWT_SECRET = "test-secret";
process.env.DATABASE_URL = ":memory:";
app = await buildApp();
await app.ready();
});
after(async () => {
await app.close();
});
describe("POST /api/auth/register", () => {
it("registers a new user", async () => {
const res = await app.inject({
method: "POST",
url: "/api/auth/register",
payload: { username: "testuser", password: "password123" },
});
assert.equal(res.statusCode, 201);
const body = res.json();
assert.ok(body.token);
assert.equal(body.user.username, "testuser");
token = body.token;
});
it("rejects duplicate username", async () => {
const res = await app.inject({
method: "POST",
url: "/api/auth/register",
payload: { username: "testuser", password: "password123" },
});
assert.equal(res.statusCode, 409);
});
it("rejects short password", async () => {
const res = await app.inject({
method: "POST",
url: "/api/auth/register",
payload: { username: "user2", password: "123" },
});
assert.equal(res.statusCode, 400);
});
});
describe("POST /api/auth/login", () => {
it("logs in with correct credentials", async () => {
const res = await app.inject({
method: "POST",
url: "/api/auth/login",
payload: { username: "testuser", password: "password123" },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.ok(body.token);
token = body.token;
});
it("rejects wrong password", async () => {
const res = await app.inject({
method: "POST",
url: "/api/auth/login",
payload: { username: "testuser", password: "wrongpassword" },
});
assert.equal(res.statusCode, 401);
});
});
describe("GET /api/auth/me", () => {
it("returns current user with valid token", async () => {
const res = await app.inject({
method: "GET",
url: "/api/auth/me",
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.equal(body.data.username, "testuser");
});
it("rejects without token", async () => {
const res = await app.inject({
method: "GET",
url: "/api/auth/me",
});
assert.equal(res.statusCode, 401);
});
});
@@ -0,0 +1,121 @@
// Customers CRUD test
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { buildApp } from "../index.js";
import type { FastifyInstance } from "fastify";
let app: FastifyInstance;
let token: string;
let createdId: string;
let payload: Record<string, unknown>;
before(async () => {
process.env.JWT_SECRET = "test-secret";
process.env.DATABASE_URL = ":memory:";
app = await buildApp();
await app.ready();
// Register and login to get a token
const regRes = await app.inject({
method: "POST",
url: "/api/auth/register",
payload: { username: `test_${Date.now()}`, password: "password123" },
});
token = regRes.json().token;
// Resolve user FK references with the registered user's real ID
payload = {
"userId": regRes.json().user.id,
"name": "sample-name",
"email": "sample-email",
"phone": "sample-phone",
"company": "sample-company",
"source": "sample-source"
};
});
after(async () => {
await app.close();
});
describe("GET /api/customers", () => {
it("returns empty list", async () => {
const res = await app.inject({
method: "GET",
url: "/api/customers",
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.ok(Array.isArray(body.data));
});
});
describe("POST /api/customers", () => {
it("creates a customer", async () => {
const res = await app.inject({
method: "POST",
url: "/api/customers",
headers: { authorization: `Bearer ${token}` },
payload,
});
assert.equal(res.statusCode, 201);
const body = res.json();
assert.ok(body.data.id);
createdId = body.data.id;
});
});
describe("GET /api/customers/:id", () => {
it("returns the created customer", async () => {
const res = await app.inject({
method: "GET",
url: `/api/customers/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.equal(body.data.id, createdId);
});
it("returns 404 for nonexistent id", async () => {
const res = await app.inject({
method: "GET",
url: `/api/customers/nonexistent`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 404);
});
});
describe("PUT /api/customers/:id", () => {
it("updates the customer", async () => {
const res = await app.inject({
method: "PUT",
url: `/api/customers/${createdId}`,
headers: { authorization: `Bearer ${token}` },
payload,
});
assert.equal(res.statusCode, 200);
});
});
describe("DELETE /api/customers/:id", () => {
it("deletes the customer", async () => {
const res = await app.inject({
method: "DELETE",
url: `/api/customers/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 204);
});
it("returns 404 after delete", async () => {
const res = await app.inject({
method: "GET",
url: `/api/customers/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 404);
});
});
@@ -0,0 +1,119 @@
// Dashboard CRUD test
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { buildApp } from "../index.js";
import type { FastifyInstance } from "fastify";
let app: FastifyInstance;
let token: string;
let createdId: string;
let payload: Record<string, unknown>;
before(async () => {
process.env.JWT_SECRET = "test-secret";
process.env.DATABASE_URL = ":memory:";
app = await buildApp();
await app.ready();
// Register and login to get a token
const regRes = await app.inject({
method: "POST",
url: "/api/auth/register",
payload: { username: `test_${Date.now()}`, password: "password123" },
});
token = regRes.json().token;
// Resolve user FK references with the registered user's real ID
payload = {
"userId": regRes.json().user.id,
"name": "sample-name",
"description": "sample-description",
"color": "sample-color"
};
});
after(async () => {
await app.close();
});
describe("GET /api/dashboard", () => {
it("returns empty list", async () => {
const res = await app.inject({
method: "GET",
url: "/api/dashboard",
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.ok(Array.isArray(body.data));
});
});
describe("POST /api/dashboard", () => {
it("creates a dashboard", async () => {
const res = await app.inject({
method: "POST",
url: "/api/dashboard",
headers: { authorization: `Bearer ${token}` },
payload,
});
assert.equal(res.statusCode, 201);
const body = res.json();
assert.ok(body.data.id);
createdId = body.data.id;
});
});
describe("GET /api/dashboard/:id", () => {
it("returns the created dashboard", async () => {
const res = await app.inject({
method: "GET",
url: `/api/dashboard/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.equal(body.data.id, createdId);
});
it("returns 404 for nonexistent id", async () => {
const res = await app.inject({
method: "GET",
url: `/api/dashboard/nonexistent`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 404);
});
});
describe("PUT /api/dashboard/:id", () => {
it("updates the dashboard", async () => {
const res = await app.inject({
method: "PUT",
url: `/api/dashboard/${createdId}`,
headers: { authorization: `Bearer ${token}` },
payload,
});
assert.equal(res.statusCode, 200);
});
});
describe("DELETE /api/dashboard/:id", () => {
it("deletes the dashboard", async () => {
const res = await app.inject({
method: "DELETE",
url: `/api/dashboard/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 204);
});
it("returns 404 after delete", async () => {
const res = await app.inject({
method: "GET",
url: `/api/dashboard/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 404);
});
});
@@ -0,0 +1,121 @@
// FollowUps CRUD test
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { buildApp } from "../index.js";
import type { FastifyInstance } from "fastify";
let app: FastifyInstance;
let token: string;
let createdId: string;
let payload: Record<string, unknown>;
before(async () => {
process.env.JWT_SECRET = "test-secret";
process.env.DATABASE_URL = ":memory:";
app = await buildApp();
await app.ready();
// Register and login to get a token
const regRes = await app.inject({
method: "POST",
url: "/api/auth/register",
payload: { username: `test_${Date.now()}`, password: "password123" },
});
token = regRes.json().token;
// Resolve user FK references with the registered user's real ID
payload = {
"userId": regRes.json().user.id,
"customerId": "sample-customerid",
"type": "sample-type",
"content": "sample-content",
"nextAction": "sample-nextaction",
"nextActionDate": "sample-nextactiondate"
};
});
after(async () => {
await app.close();
});
describe("GET /api/follow_ups", () => {
it("returns empty list", async () => {
const res = await app.inject({
method: "GET",
url: "/api/follow_ups",
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.ok(Array.isArray(body.data));
});
});
describe("POST /api/follow_ups", () => {
it("creates a follow_up", async () => {
const res = await app.inject({
method: "POST",
url: "/api/follow_ups",
headers: { authorization: `Bearer ${token}` },
payload,
});
assert.equal(res.statusCode, 201);
const body = res.json();
assert.ok(body.data.id);
createdId = body.data.id;
});
});
describe("GET /api/follow_ups/:id", () => {
it("returns the created follow_up", async () => {
const res = await app.inject({
method: "GET",
url: `/api/follow_ups/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.equal(body.data.id, createdId);
});
it("returns 404 for nonexistent id", async () => {
const res = await app.inject({
method: "GET",
url: `/api/follow_ups/nonexistent`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 404);
});
});
describe("PUT /api/follow_ups/:id", () => {
it("updates the follow_up", async () => {
const res = await app.inject({
method: "PUT",
url: `/api/follow_ups/${createdId}`,
headers: { authorization: `Bearer ${token}` },
payload,
});
assert.equal(res.statusCode, 200);
});
});
describe("DELETE /api/follow_ups/:id", () => {
it("deletes the follow_up", async () => {
const res = await app.inject({
method: "DELETE",
url: `/api/follow_ups/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 204);
});
it("returns 404 after delete", async () => {
const res = await app.inject({
method: "GET",
url: `/api/follow_ups/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 404);
});
});
@@ -0,0 +1,118 @@
// NoteTag CRUD test
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { buildApp } from "../index.js";
import type { FastifyInstance } from "fastify";
let app: FastifyInstance;
let token: string;
let createdId: string;
before(async () => {
process.env.JWT_SECRET = "test-secret";
process.env.DATABASE_URL = ":memory:";
app = await buildApp();
await app.ready();
// Register and login to get a token
const regRes = await app.inject({
method: "POST",
url: "/api/auth/register",
payload: { username: `test_${Date.now()}`, password: "password123" },
});
token = regRes.json().token;
});
after(async () => {
await app.close();
});
describe("GET /api/note_tags", () => {
it("returns empty list", async () => {
const res = await app.inject({
method: "GET",
url: "/api/note_tags",
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.ok(Array.isArray(body.data));
});
});
describe("POST /api/note_tags", () => {
it("creates a note_tag", async () => {
const res = await app.inject({
method: "POST",
url: "/api/note_tags",
headers: { authorization: `Bearer ${token}` },
payload: {
"noteId": "00000000-0000-0000-0000-000000000001",
"tagId": "00000000-0000-0000-0000-000000000001",
"PRIMARY KEY (noteId, tagId)": "sample-primary key (noteid, tagid)"
},
});
assert.equal(res.statusCode, 201);
const body = res.json();
assert.ok(body.data.id);
createdId = body.data.id;
});
});
describe("GET /api/note_tags/:id", () => {
it("returns the created note_tag", async () => {
const res = await app.inject({
method: "GET",
url: `/api/note_tags/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.equal(body.data.id, createdId);
});
it("returns 404 for nonexistent id", async () => {
const res = await app.inject({
method: "GET",
url: `/api/note_tags/nonexistent`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 404);
});
});
describe("PUT /api/note_tags/:id", () => {
it("updates the note_tag", async () => {
const res = await app.inject({
method: "PUT",
url: `/api/note_tags/${createdId}`,
headers: { authorization: `Bearer ${token}` },
payload: {
"noteId": "00000000-0000-0000-0000-000000000001",
"tagId": "00000000-0000-0000-0000-000000000001",
"PRIMARY KEY (noteId, tagId)": "sample-primary key (noteid, tagid)"
},
});
assert.equal(res.statusCode, 200);
});
});
describe("DELETE /api/note_tags/:id", () => {
it("deletes the note_tag", async () => {
const res = await app.inject({
method: "DELETE",
url: `/api/note_tags/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 204);
});
it("returns 404 after delete", async () => {
const res = await app.inject({
method: "GET",
url: `/api/note_tags/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 404);
});
});
@@ -0,0 +1,122 @@
// Note CRUD test
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { buildApp } from "../index.js";
import type { FastifyInstance } from "fastify";
let app: FastifyInstance;
let token: string;
let createdId: string;
before(async () => {
process.env.JWT_SECRET = "test-secret";
process.env.DATABASE_URL = ":memory:";
app = await buildApp();
await app.ready();
// Register and login to get a token
const regRes = await app.inject({
method: "POST",
url: "/api/auth/register",
payload: { username: `test_${Date.now()}`, password: "password123" },
});
token = regRes.json().token;
});
after(async () => {
await app.close();
});
describe("GET /api/notes", () => {
it("returns empty list", async () => {
const res = await app.inject({
method: "GET",
url: "/api/notes",
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.ok(Array.isArray(body.data));
});
});
describe("POST /api/notes", () => {
it("creates a note", async () => {
const res = await app.inject({
method: "POST",
url: "/api/notes",
headers: { authorization: `Bearer ${token}` },
payload: {
"userId": "00000000-0000-0000-0000-000000000001",
"title": "sample-title",
"content": "sample-content",
"isMarkdown": true,
"folderId": "00000000-0000-0000-0000-000000000001"
},
});
assert.equal(res.statusCode, 201);
const body = res.json();
assert.ok(body.data.id);
createdId = body.data.id;
});
});
describe("GET /api/notes/:id", () => {
it("returns the created note", async () => {
const res = await app.inject({
method: "GET",
url: `/api/notes/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.equal(body.data.id, createdId);
});
it("returns 404 for nonexistent id", async () => {
const res = await app.inject({
method: "GET",
url: `/api/notes/nonexistent`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 404);
});
});
describe("PUT /api/notes/:id", () => {
it("updates the note", async () => {
const res = await app.inject({
method: "PUT",
url: `/api/notes/${createdId}`,
headers: { authorization: `Bearer ${token}` },
payload: {
"userId": "00000000-0000-0000-0000-000000000001",
"title": "sample-title",
"content": "sample-content",
"isMarkdown": true,
"folderId": "00000000-0000-0000-0000-000000000001"
},
});
assert.equal(res.statusCode, 200);
});
});
describe("DELETE /api/notes/:id", () => {
it("deletes the note", async () => {
const res = await app.inject({
method: "DELETE",
url: `/api/notes/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 204);
});
it("returns 404 after delete", async () => {
const res = await app.inject({
method: "GET",
url: `/api/notes/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 404);
});
});
@@ -0,0 +1,121 @@
// SalesPipeline CRUD test
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { buildApp } from "../index.js";
import type { FastifyInstance } from "fastify";
let app: FastifyInstance;
let token: string;
let createdId: string;
let payload: Record<string, unknown>;
before(async () => {
process.env.JWT_SECRET = "test-secret";
process.env.DATABASE_URL = ":memory:";
app = await buildApp();
await app.ready();
// Register and login to get a token
const regRes = await app.inject({
method: "POST",
url: "/api/auth/register",
payload: { username: `test_${Date.now()}`, password: "password123" },
});
token = regRes.json().token;
// Resolve user FK references with the registered user's real ID
payload = {
"userId": regRes.json().user.id,
"customerId": "sample-customerid",
"stage": "sample-stage",
"amount": 1,
"probability": 1,
"expectedClose": "sample-expectedclose"
};
});
after(async () => {
await app.close();
});
describe("GET /api/sales_pipeline", () => {
it("returns empty list", async () => {
const res = await app.inject({
method: "GET",
url: "/api/sales_pipeline",
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.ok(Array.isArray(body.data));
});
});
describe("POST /api/sales_pipeline", () => {
it("creates a sales_pipeline", async () => {
const res = await app.inject({
method: "POST",
url: "/api/sales_pipeline",
headers: { authorization: `Bearer ${token}` },
payload,
});
assert.equal(res.statusCode, 201);
const body = res.json();
assert.ok(body.data.id);
createdId = body.data.id;
});
});
describe("GET /api/sales_pipeline/:id", () => {
it("returns the created sales_pipeline", async () => {
const res = await app.inject({
method: "GET",
url: `/api/sales_pipeline/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.equal(body.data.id, createdId);
});
it("returns 404 for nonexistent id", async () => {
const res = await app.inject({
method: "GET",
url: `/api/sales_pipeline/nonexistent`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 404);
});
});
describe("PUT /api/sales_pipeline/:id", () => {
it("updates the sales_pipeline", async () => {
const res = await app.inject({
method: "PUT",
url: `/api/sales_pipeline/${createdId}`,
headers: { authorization: `Bearer ${token}` },
payload,
});
assert.equal(res.statusCode, 200);
});
});
describe("DELETE /api/sales_pipeline/:id", () => {
it("deletes the sales_pipeline", async () => {
const res = await app.inject({
method: "DELETE",
url: `/api/sales_pipeline/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 204);
});
it("returns 404 after delete", async () => {
const res = await app.inject({
method: "GET",
url: `/api/sales_pipeline/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 404);
});
});
@@ -0,0 +1,116 @@
// Tag CRUD test
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { buildApp } from "../index.js";
import type { FastifyInstance } from "fastify";
let app: FastifyInstance;
let token: string;
let createdId: string;
before(async () => {
process.env.JWT_SECRET = "test-secret";
process.env.DATABASE_URL = ":memory:";
app = await buildApp();
await app.ready();
// Register and login to get a token
const regRes = await app.inject({
method: "POST",
url: "/api/auth/register",
payload: { username: `test_${Date.now()}`, password: "password123" },
});
token = regRes.json().token;
});
after(async () => {
await app.close();
});
describe("GET /api/tags", () => {
it("returns empty list", async () => {
const res = await app.inject({
method: "GET",
url: "/api/tags",
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.ok(Array.isArray(body.data));
});
});
describe("POST /api/tags", () => {
it("creates a tag", async () => {
const res = await app.inject({
method: "POST",
url: "/api/tags",
headers: { authorization: `Bearer ${token}` },
payload: {
"name": "sample-name",
"color": "sample-color"
},
});
assert.equal(res.statusCode, 201);
const body = res.json();
assert.ok(body.data.id);
createdId = body.data.id;
});
});
describe("GET /api/tags/:id", () => {
it("returns the created tag", async () => {
const res = await app.inject({
method: "GET",
url: `/api/tags/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.equal(body.data.id, createdId);
});
it("returns 404 for nonexistent id", async () => {
const res = await app.inject({
method: "GET",
url: `/api/tags/nonexistent`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 404);
});
});
describe("PUT /api/tags/:id", () => {
it("updates the tag", async () => {
const res = await app.inject({
method: "PUT",
url: `/api/tags/${createdId}`,
headers: { authorization: `Bearer ${token}` },
payload: {
"name": "sample-name",
"color": "sample-color"
},
});
assert.equal(res.statusCode, 200);
});
});
describe("DELETE /api/tags/:id", () => {
it("deletes the tag", async () => {
const res = await app.inject({
method: "DELETE",
url: `/api/tags/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 204);
});
it("returns 404 after delete", async () => {
const res = await app.inject({
method: "GET",
url: `/api/tags/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 404);
});
});
@@ -0,0 +1,118 @@
// User CRUD test
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { buildApp } from "../index.js";
import type { FastifyInstance } from "fastify";
let app: FastifyInstance;
let token: string;
let createdId: string;
before(async () => {
process.env.JWT_SECRET = "test-secret";
process.env.DATABASE_URL = ":memory:";
app = await buildApp();
await app.ready();
// Register and login to get a token
const regRes = await app.inject({
method: "POST",
url: "/api/auth/register",
payload: { username: `test_${Date.now()}`, password: "password123" },
});
token = regRes.json().token;
});
after(async () => {
await app.close();
});
describe("GET /api/users", () => {
it("returns empty list", async () => {
const res = await app.inject({
method: "GET",
url: "/api/users",
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.ok(Array.isArray(body.data));
});
});
describe("POST /api/users", () => {
it("creates a user", async () => {
const res = await app.inject({
method: "POST",
url: "/api/users",
headers: { authorization: `Bearer ${token}` },
payload: {
"phone": "sample-phone",
"nickname": "sample-nickname",
"avatarUrl": "sample-avatarurl"
},
});
assert.equal(res.statusCode, 201);
const body = res.json();
assert.ok(body.data.id);
createdId = body.data.id;
});
});
describe("GET /api/users/:id", () => {
it("returns the created user", async () => {
const res = await app.inject({
method: "GET",
url: `/api/users/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.equal(body.data.id, createdId);
});
it("returns 404 for nonexistent id", async () => {
const res = await app.inject({
method: "GET",
url: `/api/users/nonexistent`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 404);
});
});
describe("PUT /api/users/:id", () => {
it("updates the user", async () => {
const res = await app.inject({
method: "PUT",
url: `/api/users/${createdId}`,
headers: { authorization: `Bearer ${token}` },
payload: {
"phone": "sample-phone",
"nickname": "sample-nickname",
"avatarUrl": "sample-avatarurl"
},
});
assert.equal(res.statusCode, 200);
});
});
describe("DELETE /api/users/:id", () => {
it("deletes the user", async () => {
const res = await app.inject({
method: "DELETE",
url: `/api/users/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 204);
});
it("returns 404 after delete", async () => {
const res = await app.inject({
method: "GET",
url: `/api/users/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 404);
});
});
+93
View File
@@ -0,0 +1,93 @@
// SQLite database client (sql.js — pure WASM, no native deps)
import initSqlJs, { type Database, type BindParams } from "sql.js";
import { createTables } from "./schema.js";
let db: Database | null = null;
let initPromise: Promise<Database> | null = null;
/** Initialize the database (call once at startup). */
export async function initDb(dbPath?: string): Promise<Database> {
if (db) return db;
if (initPromise) return initPromise;
initPromise = (async () => {
const SQL = await initSqlJs();
const path = dbPath || process.env.DATABASE_URL || ":memory:";
// Try to load existing database from file
let buffer: ArrayLike<number> | undefined;
if (path !== ":memory:") {
try {
const fs = await import("node:fs/promises");
const data = await fs.readFile(path);
buffer = new Uint8Array(data);
} catch {
// File doesn't exist yet — start fresh
}
}
db = new SQL.Database(buffer);
db.run("PRAGMA foreign_keys = ON");
createTables(db);
return db;
})();
return initPromise;
}
/** Get the initialized database (must call initDb first). */
export function getDb(): Database {
if (!db) throw new Error("Database not initialized. Call initDb() first.");
return db;
}
/** Save database to disk. */
export async function saveDb(dbPath?: string): Promise<void> {
if (!db) return;
const path = dbPath || process.env.DATABASE_URL || "./data/app.db";
if (path === ":memory:") return;
const fs = await import("node:fs/promises");
const { dirname } = await import("node:path");
await fs.mkdir(dirname(path), { recursive: true });
const data = db.export();
await fs.writeFile(path, Buffer.from(data));
}
export async function closeDb(): Promise<void> {
if (db) {
await saveDb();
db.close();
db = null;
initPromise = null;
}
}
// Helper: run a query and return all rows as objects
export function queryAll<T = Record<string, unknown>>(sql: string, params: BindParams = []): T[] {
const d = getDb();
const stmt = d.prepare(sql);
if (params) stmt.bind(params);
const results: T[] = [];
while (stmt.step()) {
const row = stmt.getAsObject();
results.push(row as unknown as T);
}
stmt.free();
return results;
}
// Helper: run a query and return the first row
export function queryOne<T = Record<string, unknown>>(sql: string, params: BindParams = []): T | undefined {
const rows = queryAll<T>(sql, params);
return rows[0];
}
// Helper: run a mutation and return { changes, lastInsertRowid }
export function execute(sql: string, params: BindParams = []): { changes: number; lastInsertRowid: number } {
const d = getDb();
d.run(sql, params);
return {
changes: d.getRowsModified(),
lastInsertRowid: 0,
};
}
+16
View File
@@ -0,0 +1,16 @@
// Auto-generated SQLite schema
import type { Database } from "sql.js";
export function createTables(db: Database): void {
const statements = [
"CREATE TABLE IF NOT EXISTS users (\n id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),\n username TEXT NOT NULL UNIQUE,\n password_hash TEXT NOT NULL,\n nickname TEXT,\n role TEXT DEFAULT 'user',\n phone TEXT,\n avatar_url TEXT,\n created_at TEXT,\n updated_at TEXT\n);",
"CREATE TABLE IF NOT EXISTS customers (\n id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),\n user_id TEXT REFERENCES users(id),\n name TEXT NOT NULL,\n email TEXT,\n phone TEXT,\n company TEXT,\n source TEXT,\n tags TEXT,\n created_at TEXT,\n updated_at TEXT\n);",
"CREATE TABLE IF NOT EXISTS sales_pipeline (\n id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),\n user_id TEXT REFERENCES users(id),\n customer_id TEXT REFERENCES customers(id),\n stage TEXT NOT NULL,\n amount REAL,\n probability INTEGER,\n expected_close TEXT,\n created_at TEXT,\n updated_at TEXT\n);",
"CREATE TABLE IF NOT EXISTS follow_ups (\n id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),\n user_id TEXT REFERENCES users(id),\n customer_id TEXT REFERENCES customers(id),\n type TEXT,\n content TEXT,\n next_action TEXT,\n next_action_date TEXT,\n created_at TEXT,\n updated_at TEXT\n);",
"CREATE TABLE IF NOT EXISTS dashboard (\n id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),\n user_id TEXT REFERENCES users(id),\n name TEXT NOT NULL,\n description TEXT,\n color TEXT,\n created_at TEXT,\n updated_at TEXT\n);"
];
for (const sql of statements) {
const trimmed = sql.trim();
if (trimmed) db.run(trimmed);
}
}
+71
View File
@@ -0,0 +1,71 @@
// NoteApp — Fastify Backend Server
import Fastify from "fastify";
import cors from "@fastify/cors";
import fjwt from "@fastify/jwt";
import { initDb, closeDb } from "./db/client.js";
import { authRoutes } from "./routes/auth.js";
import { customersRoutes } from "./routes/customers.js";
import { sales_pipelineRoutes } from "./routes/sales_pipeline.js";
import { follow_upsRoutes } from "./routes/follow_ups.js";
import { dashboardRoutes } from "./routes/dashboard.js";
const JWT_SECRET = process.env.JWT_SECRET || "change-me-in-production-80f3b14d";
export async function buildApp() {
const app = Fastify({
logger: {
level: process.env.LOG_LEVEL || "info",
transport: process.env.NODE_ENV !== "production"
? { target: "pino-pretty", options: { colorize: true } }
: undefined,
},
});
// Init database
await initDb();
// Plugins
await app.register(cors, {
origin: process.env.CORS_ORIGIN || "*",
methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
});
await app.register(fjwt, { secret: JWT_SECRET });
// Routes
await app.register(authRoutes, { prefix: "/api/auth" });
await app.register(customersRoutes, { prefix: "/api/customers" });
await app.register(sales_pipelineRoutes, { prefix: "/api/sales_pipeline" });
await app.register(follow_upsRoutes, { prefix: "/api/follow_ups" });
await app.register(dashboardRoutes, { prefix: "/api/dashboard" });
// Health check
app.get("/api/health", async () => ({ status: "ok", timestamp: new Date().toISOString() }));
// Graceful shutdown
app.addHook("onClose", async () => {
closeDb();
});
return app;
}
// Start server if called directly (not when imported by tests)
const port = parseInt(process.env.PORT || "3001", 10);
const host = process.env.HOST || "0.0.0.0";
async function main() {
const app = await buildApp();
try {
await app.listen({ port, host });
} catch (err) {
app.log.error(err);
process.exit(1);
}
}
// Guard: only run when executed directly, not when imported
const isMain = process.argv[1] && (import.meta.url === `file://${process.argv[1]}` || import.meta.url.endsWith(process.argv[1]) || process.argv[1].endsWith("/src/index.ts") || process.argv[1].endsWith("/src/index.js"));
if (isMain) {
main();
}
@@ -0,0 +1,41 @@
// JWT Authentication Middleware
import type { FastifyRequest, FastifyReply } from "fastify";
import type { JwtPayload } from "../types/index.js";
/**
* Verify JWT token and attach user to request.
*/
export async function authenticate(request: FastifyRequest, reply: FastifyReply): Promise<void> {
try {
await request.jwtVerify();
} catch (err) {
reply.status(401).send({ error: "Unauthorized", message: "Invalid or expired token", statusCode: 401 });
}
}
/** Helper to get typed user from request (after authenticate). */
export function getUser(request: FastifyRequest): JwtPayload {
return request.user as unknown as JwtPayload;
}
/**
* Require admin role.
* Must be used after authenticate.
*/
export async function requireAdmin(request: FastifyRequest, reply: FastifyReply): Promise<void> {
const user = request.user as unknown as JwtPayload | undefined;
if (!user || user.role !== "admin") {
reply.status(403).send({ error: "Forbidden", message: "Admin access required", statusCode: 403 });
}
}
/**
* Optional auth: attach user if token present, but don't fail if missing.
*/
export async function optionalAuth(request: FastifyRequest): Promise<void> {
try {
await request.jwtVerify();
} catch {
// No token or invalid — continue without user
}
}
+121
View File
@@ -0,0 +1,121 @@
// Authentication routes
import type { FastifyInstance } from "fastify";
import bcrypt from "bcrypt";
import { queryOne, execute } from "../db/client.js";
import { authenticate } from "../middleware/auth.js";
import type { User, RegisterInput, LoginInput, AuthResponse } from "../types/index.js";
const SALT_ROUNDS = 10;
export async function authRoutes(app: FastifyInstance): Promise<void> {
// POST /api/auth/register
app.post<{ Body: RegisterInput }>("/register", async (request, reply) => {
const { username, password, nickname } = request.body;
if (!username || !password) {
return reply.status(400).send({
error: "Bad Request",
message: "Username and password are required",
statusCode: 400,
});
}
if (password.length < 6) {
return reply.status(400).send({
error: "Bad Request",
message: "Password must be at least 6 characters",
statusCode: 400,
});
}
const existing = queryOne("SELECT id FROM users WHERE username = ?", [username]);
if (existing) {
return reply.status(409).send({
error: "Conflict",
message: "Username already exists",
statusCode: 409,
});
}
const id = crypto.randomUUID();
const passwordHash = await bcrypt.hash(password, SALT_ROUNDS);
const now = new Date().toISOString();
execute(
"INSERT INTO users (id, username, password_hash, nickname, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
[id, username, passwordHash, nickname || username, now, now]
);
const user = queryOne<User>(
"SELECT id, username, nickname, role, created_at, updated_at FROM users WHERE id = ?",
[id]
);
if (!user) {
return reply.status(500).send({ error: "Internal Error", message: "Failed to create user", statusCode: 500 });
}
const token = app.jwt.sign({ userId: id, username, role: user.role });
return reply.status(201).send({ token, user } satisfies AuthResponse);
});
// POST /api/auth/login
app.post<{ Body: LoginInput }>("/login", async (request, reply) => {
const { username, password } = request.body;
if (!username || !password) {
return reply.status(400).send({
error: "Bad Request",
message: "Username and password are required",
statusCode: 400,
});
}
const user = queryOne<User & { password_hash: string }>(
"SELECT * FROM users WHERE username = ?",
[username]
);
if (!user) {
return reply.status(401).send({
error: "Unauthorized",
message: "Invalid username or password",
statusCode: 401,
});
}
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) {
return reply.status(401).send({
error: "Unauthorized",
message: "Invalid username or password",
statusCode: 401,
});
}
const token = app.jwt.sign({ userId: user.id, username: user.username, role: user.role });
const { password_hash, ...safeUser } = user;
return { token, user: safeUser } satisfies AuthResponse;
});
// GET /api/auth/me — current user info
app.get("/me", { onRequest: [authenticate] }, async (request, reply) => {
const jwtUser = request.user as unknown as { userId: string };
const user = queryOne<User>(
"SELECT id, username, nickname, role, created_at, updated_at FROM users WHERE id = ?",
[jwtUser.userId]
);
if (!user) {
return reply.status(404).send({
error: "Not Found",
message: "User not found",
statusCode: 404,
});
}
return { data: user };
});
}
@@ -0,0 +1,51 @@
// Auto-generated Customers routes
import type { FastifyInstance } from "fastify";
import { authenticate } from "../middleware/auth.js";
import { CustomersService } from "../services/customer.js";
import type { CreateCustomersInput, UpdateCustomersInput } from "../types/index.js";
const service = new CustomersService();
export async function customersRoutes(app: FastifyInstance): Promise<void> {
// All routes require authentication
app.addHook("onRequest", authenticate);
// GET /api/customers — list
app.get("/", async (request, reply) => {
const items = service.list();
return { data: items };
});
// GET /api/customers/:id — get by id
app.get<{ Params: { id: string } }>("/:id", async (request, reply) => {
const item = service.getById(request.params.id);
if (!item) {
return reply.status(404).send({ error: "Not Found", message: "Customers not found", statusCode: 404 });
}
return { data: item };
});
// POST /api/customers — create
app.post<{ Body: CreateCustomersInput }>("/", async (request, reply) => {
const item = service.create(request.body);
return reply.status(201).send({ data: item });
});
// PUT /api/customers/:id — update
app.put<{ Params: { id: string }; Body: UpdateCustomersInput }>("/:id", async (request, reply) => {
const item = service.update(request.params.id, request.body);
if (!item) {
return reply.status(404).send({ error: "Not Found", message: "Customers not found", statusCode: 404 });
}
return { data: item };
});
// DELETE /api/customers/:id — delete
app.delete<{ Params: { id: string } }>("/:id", async (request, reply) => {
const deleted = service.delete(request.params.id);
if (!deleted) {
return reply.status(404).send({ error: "Not Found", message: "Customers not found", statusCode: 404 });
}
return reply.status(204).send();
});
}
@@ -0,0 +1,51 @@
// Auto-generated Dashboard routes
import type { FastifyInstance } from "fastify";
import { authenticate } from "../middleware/auth.js";
import { DashboardService } from "../services/dashboard.js";
import type { CreateDashboardInput, UpdateDashboardInput } from "../types/index.js";
const service = new DashboardService();
export async function dashboardRoutes(app: FastifyInstance): Promise<void> {
// All routes require authentication
app.addHook("onRequest", authenticate);
// GET /api/dashboard — list
app.get("/", async (request, reply) => {
const items = service.list();
return { data: items };
});
// GET /api/dashboard/:id — get by id
app.get<{ Params: { id: string } }>("/:id", async (request, reply) => {
const item = service.getById(request.params.id);
if (!item) {
return reply.status(404).send({ error: "Not Found", message: "Dashboard not found", statusCode: 404 });
}
return { data: item };
});
// POST /api/dashboard — create
app.post<{ Body: CreateDashboardInput }>("/", async (request, reply) => {
const item = service.create(request.body);
return reply.status(201).send({ data: item });
});
// PUT /api/dashboard/:id — update
app.put<{ Params: { id: string }; Body: UpdateDashboardInput }>("/:id", async (request, reply) => {
const item = service.update(request.params.id, request.body);
if (!item) {
return reply.status(404).send({ error: "Not Found", message: "Dashboard not found", statusCode: 404 });
}
return { data: item };
});
// DELETE /api/dashboard/:id — delete
app.delete<{ Params: { id: string } }>("/:id", async (request, reply) => {
const deleted = service.delete(request.params.id);
if (!deleted) {
return reply.status(404).send({ error: "Not Found", message: "Dashboard not found", statusCode: 404 });
}
return reply.status(204).send();
});
}
@@ -0,0 +1,51 @@
// Auto-generated FollowUps routes
import type { FastifyInstance } from "fastify";
import { authenticate } from "../middleware/auth.js";
import { FollowUpsService } from "../services/follow_up.js";
import type { CreateFollowUpsInput, UpdateFollowUpsInput } from "../types/index.js";
const service = new FollowUpsService();
export async function follow_upsRoutes(app: FastifyInstance): Promise<void> {
// All routes require authentication
app.addHook("onRequest", authenticate);
// GET /api/follow_ups — list
app.get("/", async (request, reply) => {
const items = service.list();
return { data: items };
});
// GET /api/follow_ups/:id — get by id
app.get<{ Params: { id: string } }>("/:id", async (request, reply) => {
const item = service.getById(request.params.id);
if (!item) {
return reply.status(404).send({ error: "Not Found", message: "FollowUps not found", statusCode: 404 });
}
return { data: item };
});
// POST /api/follow_ups — create
app.post<{ Body: CreateFollowUpsInput }>("/", async (request, reply) => {
const item = service.create(request.body);
return reply.status(201).send({ data: item });
});
// PUT /api/follow_ups/:id — update
app.put<{ Params: { id: string }; Body: UpdateFollowUpsInput }>("/:id", async (request, reply) => {
const item = service.update(request.params.id, request.body);
if (!item) {
return reply.status(404).send({ error: "Not Found", message: "FollowUps not found", statusCode: 404 });
}
return { data: item };
});
// DELETE /api/follow_ups/:id — delete
app.delete<{ Params: { id: string } }>("/:id", async (request, reply) => {
const deleted = service.delete(request.params.id);
if (!deleted) {
return reply.status(404).send({ error: "Not Found", message: "FollowUps not found", statusCode: 404 });
}
return reply.status(204).send();
});
}
@@ -0,0 +1,51 @@
// Auto-generated NoteTag routes
import type { FastifyInstance } from "fastify";
import { authenticate } from "../middleware/auth.js";
import { NoteTagService } from "../services/note_tag.js";
import type { CreateNoteTagInput, UpdateNoteTagInput } from "../types/index.js";
const service = new NoteTagService();
export async function note_tagsRoutes(app: FastifyInstance): Promise<void> {
// All routes require authentication
app.addHook("onRequest", authenticate);
// GET /api/note_tags — list
app.get("/", async (request, reply) => {
const items = service.list();
return { data: items };
});
// GET /api/note_tags/:id — get by id
app.get<{ Params: { id: string } }>("/:id", async (request, reply) => {
const item = service.getById(request.params.id);
if (!item) {
return reply.status(404).send({ error: "Not Found", message: "NoteTag not found", statusCode: 404 });
}
return { data: item };
});
// POST /api/note_tags — create
app.post<{ Body: CreateNoteTagInput }>("/", async (request, reply) => {
const item = service.create(request.body);
return reply.status(201).send({ data: item });
});
// PUT /api/note_tags/:id — update
app.put<{ Params: { id: string }; Body: UpdateNoteTagInput }>("/:id", async (request, reply) => {
const item = service.update(request.params.id, request.body);
if (!item) {
return reply.status(404).send({ error: "Not Found", message: "NoteTag not found", statusCode: 404 });
}
return { data: item };
});
// DELETE /api/note_tags/:id — delete
app.delete<{ Params: { id: string } }>("/:id", async (request, reply) => {
const deleted = service.delete(request.params.id);
if (!deleted) {
return reply.status(404).send({ error: "Not Found", message: "NoteTag not found", statusCode: 404 });
}
return reply.status(204).send();
});
}
@@ -0,0 +1,51 @@
// Auto-generated Note routes
import type { FastifyInstance } from "fastify";
import { authenticate } from "../middleware/auth.js";
import { NoteService } from "../services/note.js";
import type { CreateNoteInput, UpdateNoteInput } from "../types/index.js";
const service = new NoteService();
export async function notesRoutes(app: FastifyInstance): Promise<void> {
// All routes require authentication
app.addHook("onRequest", authenticate);
// GET /api/notes — list
app.get("/", async (request, reply) => {
const items = service.list();
return { data: items };
});
// GET /api/notes/:id — get by id
app.get<{ Params: { id: string } }>("/:id", async (request, reply) => {
const item = service.getById(request.params.id);
if (!item) {
return reply.status(404).send({ error: "Not Found", message: "Note not found", statusCode: 404 });
}
return { data: item };
});
// POST /api/notes — create
app.post<{ Body: CreateNoteInput }>("/", async (request, reply) => {
const item = service.create(request.body);
return reply.status(201).send({ data: item });
});
// PUT /api/notes/:id — update
app.put<{ Params: { id: string }; Body: UpdateNoteInput }>("/:id", async (request, reply) => {
const item = service.update(request.params.id, request.body);
if (!item) {
return reply.status(404).send({ error: "Not Found", message: "Note not found", statusCode: 404 });
}
return { data: item };
});
// DELETE /api/notes/:id — delete
app.delete<{ Params: { id: string } }>("/:id", async (request, reply) => {
const deleted = service.delete(request.params.id);
if (!deleted) {
return reply.status(404).send({ error: "Not Found", message: "Note not found", statusCode: 404 });
}
return reply.status(204).send();
});
}
@@ -0,0 +1,51 @@
// Auto-generated SalesPipeline routes
import type { FastifyInstance } from "fastify";
import { authenticate } from "../middleware/auth.js";
import { SalesPipelineService } from "../services/sales_pipeline.js";
import type { CreateSalesPipelineInput, UpdateSalesPipelineInput } from "../types/index.js";
const service = new SalesPipelineService();
export async function sales_pipelineRoutes(app: FastifyInstance): Promise<void> {
// All routes require authentication
app.addHook("onRequest", authenticate);
// GET /api/sales_pipeline — list
app.get("/", async (request, reply) => {
const items = service.list();
return { data: items };
});
// GET /api/sales_pipeline/:id — get by id
app.get<{ Params: { id: string } }>("/:id", async (request, reply) => {
const item = service.getById(request.params.id);
if (!item) {
return reply.status(404).send({ error: "Not Found", message: "SalesPipeline not found", statusCode: 404 });
}
return { data: item };
});
// POST /api/sales_pipeline — create
app.post<{ Body: CreateSalesPipelineInput }>("/", async (request, reply) => {
const item = service.create(request.body);
return reply.status(201).send({ data: item });
});
// PUT /api/sales_pipeline/:id — update
app.put<{ Params: { id: string }; Body: UpdateSalesPipelineInput }>("/:id", async (request, reply) => {
const item = service.update(request.params.id, request.body);
if (!item) {
return reply.status(404).send({ error: "Not Found", message: "SalesPipeline not found", statusCode: 404 });
}
return { data: item };
});
// DELETE /api/sales_pipeline/:id — delete
app.delete<{ Params: { id: string } }>("/:id", async (request, reply) => {
const deleted = service.delete(request.params.id);
if (!deleted) {
return reply.status(404).send({ error: "Not Found", message: "SalesPipeline not found", statusCode: 404 });
}
return reply.status(204).send();
});
}
+51
View File
@@ -0,0 +1,51 @@
// Auto-generated Tag routes
import type { FastifyInstance } from "fastify";
import { authenticate } from "../middleware/auth.js";
import { TagService } from "../services/tag.js";
import type { CreateTagInput, UpdateTagInput } from "../types/index.js";
const service = new TagService();
export async function tagsRoutes(app: FastifyInstance): Promise<void> {
// All routes require authentication
app.addHook("onRequest", authenticate);
// GET /api/tags — list
app.get("/", async (request, reply) => {
const items = service.list();
return { data: items };
});
// GET /api/tags/:id — get by id
app.get<{ Params: { id: string } }>("/:id", async (request, reply) => {
const item = service.getById(request.params.id);
if (!item) {
return reply.status(404).send({ error: "Not Found", message: "Tag not found", statusCode: 404 });
}
return { data: item };
});
// POST /api/tags — create
app.post<{ Body: CreateTagInput }>("/", async (request, reply) => {
const item = service.create(request.body);
return reply.status(201).send({ data: item });
});
// PUT /api/tags/:id — update
app.put<{ Params: { id: string }; Body: UpdateTagInput }>("/:id", async (request, reply) => {
const item = service.update(request.params.id, request.body);
if (!item) {
return reply.status(404).send({ error: "Not Found", message: "Tag not found", statusCode: 404 });
}
return { data: item };
});
// DELETE /api/tags/:id — delete
app.delete<{ Params: { id: string } }>("/:id", async (request, reply) => {
const deleted = service.delete(request.params.id);
if (!deleted) {
return reply.status(404).send({ error: "Not Found", message: "Tag not found", statusCode: 404 });
}
return reply.status(204).send();
});
}
@@ -0,0 +1,51 @@
// Auto-generated User routes
import type { FastifyInstance } from "fastify";
import { authenticate } from "../middleware/auth.js";
import { UserService } from "../services/user.js";
import type { CreateUserInput, UpdateUserInput } from "../types/index.js";
const service = new UserService();
export async function usersRoutes(app: FastifyInstance): Promise<void> {
// All routes require authentication
app.addHook("onRequest", authenticate);
// GET /api/users — list
app.get("/", async (request, reply) => {
const items = service.list();
return { data: items };
});
// GET /api/users/:id — get by id
app.get<{ Params: { id: string } }>("/:id", async (request, reply) => {
const item = service.getById(request.params.id);
if (!item) {
return reply.status(404).send({ error: "Not Found", message: "User not found", statusCode: 404 });
}
return { data: item };
});
// POST /api/users — create
app.post<{ Body: CreateUserInput }>("/", async (request, reply) => {
const item = service.create(request.body);
return reply.status(201).send({ data: item });
});
// PUT /api/users/:id — update
app.put<{ Params: { id: string }; Body: UpdateUserInput }>("/:id", async (request, reply) => {
const item = service.update(request.params.id, request.body);
if (!item) {
return reply.status(404).send({ error: "Not Found", message: "User not found", statusCode: 404 });
}
return { data: item };
});
// DELETE /api/users/:id — delete
app.delete<{ Params: { id: string } }>("/:id", async (request, reply) => {
const deleted = service.delete(request.params.id);
if (!deleted) {
return reply.status(404).send({ error: "Not Found", message: "User not found", statusCode: 404 });
}
return reply.status(204).send();
});
}
@@ -0,0 +1,56 @@
// Auto-generated Customers service
import { queryAll, queryOne, execute } from "../db/client.js";
import type { Customers, CreateCustomersInput, UpdateCustomersInput } from "../types/index.js";
export class CustomersService {
/** List all customers */
list(): Customers[] {
return queryAll<Customers>("SELECT * FROM customers ORDER BY created_at DESC");
}
/** Get by ID */
getById(id: string): Customers | undefined {
return queryOne<Customers>("SELECT * FROM customers WHERE id = ?", [id]);
}
/** Create */
create(input: CreateCustomersInput): Customers {
const id = crypto.randomUUID();
const now = new Date().toISOString();
const cols = ["id", "user_id", "name", "email", "phone", "company", "source", "created_at", "updated_at"];
const values = [id, input.userId ?? null, input.name ?? null, input.email ?? null, input.phone ?? null, input.company ?? null, input.source ?? null, now, now];
execute(`INSERT INTO customers (${cols.join(", ")}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, values);
return this.getById(id)!;
}
/** Update */
update(id: string, input: UpdateCustomersInput): Customers | undefined {
const existing = this.getById(id);
if (!existing) return undefined;
const sets: string[] = [];
const values: unknown[] = [];
if (input.userId !== undefined) { sets.push("user_id = ?"); values.push(input.userId); }
if (input.name !== undefined) { sets.push("name = ?"); values.push(input.name); }
if (input.email !== undefined) { sets.push("email = ?"); values.push(input.email); }
if (input.phone !== undefined) { sets.push("phone = ?"); values.push(input.phone); }
if (input.company !== undefined) { sets.push("company = ?"); values.push(input.company); }
if (input.source !== undefined) { sets.push("source = ?"); values.push(input.source); }
if (sets.length === 0) return existing;
sets.push("updated_at = ?");
values.push(new Date().toISOString());
values.push(id);
execute(`UPDATE customers SET ${sets.join(", ")} WHERE id = ?`, values);
return this.getById(id)!;
}
/** Delete */
delete(id: string): boolean {
const result = execute("DELETE FROM customers WHERE id = ?", [id]);
return result.changes > 0;
}
}
@@ -0,0 +1,54 @@
// Auto-generated Dashboard service
import { queryAll, queryOne, execute } from "../db/client.js";
import type { Dashboard, CreateDashboardInput, UpdateDashboardInput } from "../types/index.js";
export class DashboardService {
/** List all dashboard */
list(): Dashboard[] {
return queryAll<Dashboard>("SELECT * FROM dashboard ORDER BY created_at DESC");
}
/** Get by ID */
getById(id: string): Dashboard | undefined {
return queryOne<Dashboard>("SELECT * FROM dashboard WHERE id = ?", [id]);
}
/** Create */
create(input: CreateDashboardInput): Dashboard {
const id = crypto.randomUUID();
const now = new Date().toISOString();
const cols = ["id", "user_id", "name", "description", "color", "created_at", "updated_at"];
const values = [id, input.userId ?? null, input.name ?? null, input.description ?? null, input.color ?? null, now, now];
execute(`INSERT INTO dashboard (${cols.join(", ")}) VALUES (?, ?, ?, ?, ?, ?, ?)`, values);
return this.getById(id)!;
}
/** Update */
update(id: string, input: UpdateDashboardInput): Dashboard | undefined {
const existing = this.getById(id);
if (!existing) return undefined;
const sets: string[] = [];
const values: unknown[] = [];
if (input.userId !== undefined) { sets.push("user_id = ?"); values.push(input.userId); }
if (input.name !== undefined) { sets.push("name = ?"); values.push(input.name); }
if (input.description !== undefined) { sets.push("description = ?"); values.push(input.description); }
if (input.color !== undefined) { sets.push("color = ?"); values.push(input.color); }
if (sets.length === 0) return existing;
sets.push("updated_at = ?");
values.push(new Date().toISOString());
values.push(id);
execute(`UPDATE dashboard SET ${sets.join(", ")} WHERE id = ?`, values);
return this.getById(id)!;
}
/** Delete */
delete(id: string): boolean {
const result = execute("DELETE FROM dashboard WHERE id = ?", [id]);
return result.changes > 0;
}
}
@@ -0,0 +1,56 @@
// Auto-generated FollowUps service
import { queryAll, queryOne, execute } from "../db/client.js";
import type { FollowUps, CreateFollowUpsInput, UpdateFollowUpsInput } from "../types/index.js";
export class FollowUpsService {
/** List all follow_ups */
list(): FollowUps[] {
return queryAll<FollowUps>("SELECT * FROM follow_ups ORDER BY created_at DESC");
}
/** Get by ID */
getById(id: string): FollowUps | undefined {
return queryOne<FollowUps>("SELECT * FROM follow_ups WHERE id = ?", [id]);
}
/** Create */
create(input: CreateFollowUpsInput): FollowUps {
const id = crypto.randomUUID();
const now = new Date().toISOString();
const cols = ["id", "user_id", "customer_id", "type", "content", "next_action", "next_action_date", "created_at", "updated_at"];
const values = [id, input.userId ?? null, input.customerId ?? null, input.type ?? null, input.content ?? null, input.nextAction ?? null, input.nextActionDate ?? null, now, now];
execute(`INSERT INTO follow_ups (${cols.join(", ")}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, values);
return this.getById(id)!;
}
/** Update */
update(id: string, input: UpdateFollowUpsInput): FollowUps | undefined {
const existing = this.getById(id);
if (!existing) return undefined;
const sets: string[] = [];
const values: unknown[] = [];
if (input.userId !== undefined) { sets.push("user_id = ?"); values.push(input.userId); }
if (input.customerId !== undefined) { sets.push("customer_id = ?"); values.push(input.customerId); }
if (input.type !== undefined) { sets.push("type = ?"); values.push(input.type); }
if (input.content !== undefined) { sets.push("content = ?"); values.push(input.content); }
if (input.nextAction !== undefined) { sets.push("next_action = ?"); values.push(input.nextAction); }
if (input.nextActionDate !== undefined) { sets.push("next_action_date = ?"); values.push(input.nextActionDate); }
if (sets.length === 0) return existing;
sets.push("updated_at = ?");
values.push(new Date().toISOString());
values.push(id);
execute(`UPDATE follow_ups SET ${sets.join(", ")} WHERE id = ?`, values);
return this.getById(id)!;
}
/** Delete */
delete(id: string): boolean {
const result = execute("DELETE FROM follow_ups WHERE id = ?", [id]);
return result.changes > 0;
}
}
@@ -0,0 +1,58 @@
// Auto-generated Note service
import { queryAll, queryOne, execute } from "../db/client.js";
import type { Note, CreateNoteInput, UpdateNoteInput } from "../types/index.js";
export class NoteService {
/** List all notes */
list(): Note[] {
return queryAll<Note>("SELECT * FROM notes ORDER BY created_at DESC");
}
/** Get by ID */
getById(id: string): Note | undefined {
return queryOne<Note>("SELECT * FROM notes WHERE id = ?", [id]);
}
/** Create */
create(input: CreateNoteInput): Note {
const id = crypto.randomUUID();
const now = new Date().toISOString();
const hasCreatedAt = true;
const hasUpdatedAt = true;
const cols = ["id", "user_id", "title", "content", "is_markdown", "folder_id", "created_at", "updated_at"];
const placeholders = cols.map(() => "?").join(", ");
const values = [id, input.userId ?? null, input.title ?? null, input.content ?? null, input.isMarkdown ?? null, input.folderId ?? null, now, now];
execute(`INSERT INTO notes (${cols.join(", ")}) VALUES (${placeholders})`, values);
return this.getById(id)!;
}
/** Update */
update(id: string, input: UpdateNoteInput): Note | undefined {
const existing = this.getById(id);
if (!existing) return undefined;
const sets: string[] = [];
const values: unknown[] = [];
if (input.userId !== undefined) { sets.push("user_id = ?"); values.push(input.userId); }
if (input.title !== undefined) { sets.push("title = ?"); values.push(input.title); }
if (input.content !== undefined) { sets.push("content = ?"); values.push(input.content); }
if (input.isMarkdown !== undefined) { sets.push("is_markdown = ?"); values.push(input.isMarkdown); }
if (input.folderId !== undefined) { sets.push("folder_id = ?"); values.push(input.folderId); }
if (sets.length === 0) return existing;
sets.push("updated_at = ?");
values.push(new Date().toISOString());
values.push(id);
execute(`UPDATE notes SET ${sets.join(", ")} WHERE id = ?`, values);
return this.getById(id)!;
}
/** Delete */
delete(id: string): boolean {
const result = execute("DELETE FROM notes WHERE id = ?", [id]);
return result.changes > 0;
}
}
@@ -0,0 +1,54 @@
// Auto-generated NoteTag service
import { queryAll, queryOne, execute } from "../db/client.js";
import type { NoteTag, CreateNoteTagInput, UpdateNoteTagInput } from "../types/index.js";
export class NoteTagService {
/** List all note_tags */
list(): NoteTag[] {
return queryAll<NoteTag>("SELECT * FROM note_tags ORDER BY created_at DESC");
}
/** Get by ID */
getById(id: string): NoteTag | undefined {
return queryOne<NoteTag>("SELECT * FROM note_tags WHERE id = ?", [id]);
}
/** Create */
create(input: CreateNoteTagInput): NoteTag {
const id = crypto.randomUUID();
const now = new Date().toISOString();
const hasCreatedAt = false;
const hasUpdatedAt = false;
const cols = ["id", "note_id", "tag_id"];
const placeholders = cols.map(() => "?").join(", ");
const values = [id, input.noteId ?? null, input.tagId ?? null];
execute(`INSERT INTO note_tags (${cols.join(", ")}) VALUES (${placeholders})`, values);
return this.getById(id)!;
}
/** Update */
update(id: string, input: UpdateNoteTagInput): NoteTag | undefined {
const existing = this.getById(id);
if (!existing) return undefined;
const sets: string[] = [];
const values: unknown[] = [];
if (input.noteId !== undefined) { sets.push("note_id = ?"); values.push(input.noteId); }
if (input.tagId !== undefined) { sets.push("tag_id = ?"); values.push(input.tagId); }
if (sets.length === 0) return existing;
values.push(id);
execute(`UPDATE note_tags SET ${sets.join(", ")} WHERE id = ?`, values);
return this.getById(id)!;
}
/** Delete */
delete(id: string): boolean {
const result = execute("DELETE FROM note_tags WHERE id = ?", [id]);
return result.changes > 0;
}
}
@@ -0,0 +1,56 @@
// Auto-generated SalesPipeline service
import { queryAll, queryOne, execute } from "../db/client.js";
import type { SalesPipeline, CreateSalesPipelineInput, UpdateSalesPipelineInput } from "../types/index.js";
export class SalesPipelineService {
/** List all sales_pipeline */
list(): SalesPipeline[] {
return queryAll<SalesPipeline>("SELECT * FROM sales_pipeline ORDER BY created_at DESC");
}
/** Get by ID */
getById(id: string): SalesPipeline | undefined {
return queryOne<SalesPipeline>("SELECT * FROM sales_pipeline WHERE id = ?", [id]);
}
/** Create */
create(input: CreateSalesPipelineInput): SalesPipeline {
const id = crypto.randomUUID();
const now = new Date().toISOString();
const cols = ["id", "user_id", "customer_id", "stage", "amount", "probability", "expected_close", "created_at", "updated_at"];
const values = [id, input.userId ?? null, input.customerId ?? null, input.stage ?? null, input.amount ?? null, input.probability ?? null, input.expectedClose ?? null, now, now];
execute(`INSERT INTO sales_pipeline (${cols.join(", ")}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, values);
return this.getById(id)!;
}
/** Update */
update(id: string, input: UpdateSalesPipelineInput): SalesPipeline | undefined {
const existing = this.getById(id);
if (!existing) return undefined;
const sets: string[] = [];
const values: unknown[] = [];
if (input.userId !== undefined) { sets.push("user_id = ?"); values.push(input.userId); }
if (input.customerId !== undefined) { sets.push("customer_id = ?"); values.push(input.customerId); }
if (input.stage !== undefined) { sets.push("stage = ?"); values.push(input.stage); }
if (input.amount !== undefined) { sets.push("amount = ?"); values.push(input.amount); }
if (input.probability !== undefined) { sets.push("probability = ?"); values.push(input.probability); }
if (input.expectedClose !== undefined) { sets.push("expected_close = ?"); values.push(input.expectedClose); }
if (sets.length === 0) return existing;
sets.push("updated_at = ?");
values.push(new Date().toISOString());
values.push(id);
execute(`UPDATE sales_pipeline SET ${sets.join(", ")} WHERE id = ?`, values);
return this.getById(id)!;
}
/** Delete */
delete(id: string): boolean {
const result = execute("DELETE FROM sales_pipeline WHERE id = ?", [id]);
return result.changes > 0;
}
}
@@ -0,0 +1,54 @@
// Auto-generated Tag service
import { queryAll, queryOne, execute } from "../db/client.js";
import type { Tag, CreateTagInput, UpdateTagInput } from "../types/index.js";
export class TagService {
/** List all tags */
list(): Tag[] {
return queryAll<Tag>("SELECT * FROM tags ORDER BY created_at DESC");
}
/** Get by ID */
getById(id: string): Tag | undefined {
return queryOne<Tag>("SELECT * FROM tags WHERE id = ?", [id]);
}
/** Create */
create(input: CreateTagInput): Tag {
const id = crypto.randomUUID();
const now = new Date().toISOString();
const hasCreatedAt = false;
const hasUpdatedAt = false;
const cols = ["id", "name", "color"];
const placeholders = cols.map(() => "?").join(", ");
const values = [id, input.name ?? null, input.color ?? null];
execute(`INSERT INTO tags (${cols.join(", ")}) VALUES (${placeholders})`, values);
return this.getById(id)!;
}
/** Update */
update(id: string, input: UpdateTagInput): Tag | undefined {
const existing = this.getById(id);
if (!existing) return undefined;
const sets: string[] = [];
const values: unknown[] = [];
if (input.name !== undefined) { sets.push("name = ?"); values.push(input.name); }
if (input.color !== undefined) { sets.push("color = ?"); values.push(input.color); }
if (sets.length === 0) return existing;
values.push(id);
execute(`UPDATE tags SET ${sets.join(", ")} WHERE id = ?`, values);
return this.getById(id)!;
}
/** Delete */
delete(id: string): boolean {
const result = execute("DELETE FROM tags WHERE id = ?", [id]);
return result.changes > 0;
}
}
@@ -0,0 +1,56 @@
// Auto-generated User service
import { queryAll, queryOne, execute } from "../db/client.js";
import type { User, CreateUserInput, UpdateUserInput } from "../types/index.js";
export class UserService {
/** List all users */
list(): User[] {
return queryAll<User>("SELECT * FROM users ORDER BY created_at DESC");
}
/** Get by ID */
getById(id: string): User | undefined {
return queryOne<User>("SELECT * FROM users WHERE id = ?", [id]);
}
/** Create */
create(input: CreateUserInput): User {
const id = crypto.randomUUID();
const now = new Date().toISOString();
const hasCreatedAt = true;
const hasUpdatedAt = true;
const cols = ["id", "phone", "nickname", "avatar_url", "created_at", "updated_at"];
const placeholders = cols.map(() => "?").join(", ");
const values = [id, input.phone ?? null, input.nickname ?? null, input.avatarUrl ?? null, now, now];
execute(`INSERT INTO users (${cols.join(", ")}) VALUES (${placeholders})`, values);
return this.getById(id)!;
}
/** Update */
update(id: string, input: UpdateUserInput): User | undefined {
const existing = this.getById(id);
if (!existing) return undefined;
const sets: string[] = [];
const values: unknown[] = [];
if (input.phone !== undefined) { sets.push("phone = ?"); values.push(input.phone); }
if (input.nickname !== undefined) { sets.push("nickname = ?"); values.push(input.nickname); }
if (input.avatarUrl !== undefined) { sets.push("avatar_url = ?"); values.push(input.avatarUrl); }
if (sets.length === 0) return existing;
sets.push("updated_at = ?");
values.push(new Date().toISOString());
values.push(id);
execute(`UPDATE users SET ${sets.join(", ")} WHERE id = ?`, values);
return this.getById(id)!;
}
/** Delete */
delete(id: string): boolean {
const result = execute("DELETE FROM users WHERE id = ?", [id]);
return result.changes > 0;
}
}
+8
View File
@@ -0,0 +1,8 @@
// Augment Fastify request with JWT user
import type { JwtPayload } from "./index.js";
declare module "fastify" {
interface FastifyRequest {
user?: JwtPayload;
}
}
+191
View File
@@ -0,0 +1,191 @@
// Auto-generated types (from Model Contract)
export interface User {
id?: string;
username: string;
passwordHash: string;
nickname?: string;
role?: string;
phone?: string;
avatarUrl?: string;
createdAt?: string;
updatedAt?: string;
}
export interface Customers {
id: string;
userId?: string;
name: string;
email?: string;
phone?: string;
company?: string;
source?: string;
tags?: Record<string, unknown>;
createdAt?: string;
updatedAt?: string;
}
export interface SalesPipeline {
id: string;
userId?: string;
customerId?: string;
stage: string;
amount?: number;
probability?: number;
expectedClose?: string;
createdAt?: string;
updatedAt?: string;
}
export interface FollowUps {
id: string;
userId?: string;
customerId?: string;
type?: string;
content?: string;
nextAction?: string;
nextActionDate?: string;
createdAt?: string;
updatedAt?: string;
}
export interface Dashboard {
id: string;
userId?: string;
name: string;
description?: string;
color?: string;
createdAt?: string;
updatedAt?: string;
}
export interface CreateUserInput {
username: string;
passwordHash: string;
nickname?: string;
role?: string;
phone?: string;
avatarUrl?: string;
}
export interface CreateCustomersInput {
userId?: string;
name: string;
email?: string;
phone?: string;
company?: string;
source?: string;
}
export interface CreateSalesPipelineInput {
userId?: string;
customerId?: string;
stage: string;
amount?: number;
probability?: number;
expectedClose?: string;
}
export interface CreateFollowUpsInput {
userId?: string;
customerId?: string;
type?: string;
content?: string;
nextAction?: string;
nextActionDate?: string;
}
export interface CreateDashboardInput {
userId?: string;
name: string;
description?: string;
color?: string;
}
export interface UpdateUserInput {
username?: string;
passwordHash?: string;
nickname?: string;
role?: string;
phone?: string;
avatarUrl?: string;
}
export interface UpdateCustomersInput {
userId?: string;
name?: string;
email?: string;
phone?: string;
company?: string;
source?: string;
}
export interface UpdateSalesPipelineInput {
userId?: string;
customerId?: string;
stage?: string;
amount?: number;
probability?: number;
expectedClose?: string;
}
export interface UpdateFollowUpsInput {
userId?: string;
customerId?: string;
type?: string;
content?: string;
nextAction?: string;
nextActionDate?: string;
}
export interface UpdateDashboardInput {
userId?: string;
name?: string;
description?: string;
color?: string;
}
// ─── Auth ───────────────────────────────────────────
export interface LoginInput {
username: string;
password: string;
}
export interface RegisterInput {
username: string;
password: string;
nickname?: string;
}
export interface AuthResponse {
token: string;
user: User;
}
// ─── API ────────────────────────────────────────────
export interface ApiResponse<T> {
data: T;
message?: string;
}
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
pageSize: number;
}
export interface ErrorResponse {
error: string;
message: string;
statusCode: number;
}
// ─── JWT ────────────────────────────────────────────
export interface JwtPayload {
userId: string;
username: string;
role: string;
iat?: number;
exp?: number;
}
+27
View File
@@ -0,0 +1,27 @@
// Type declarations for sql.js (no native types package available)
declare module "sql.js" {
export interface Database {
run(sql: string, params?: BindParams): void;
exec(sql: string): void;
prepare(sql: string): Statement;
export(): Uint8Array;
close(): void;
getRowsModified(): number;
}
export interface Statement {
bind(params?: BindParams): boolean;
step(): boolean;
getAsObject<T = Record<string, unknown>>(): T;
getColumnNames(): string[];
free(): boolean;
}
export type BindParams = unknown[] | Record<string, unknown>;
export interface SqlJsStatic {
Database: new (data?: ArrayLike<number>) => Database;
}
export default function initSqlJs(config?: Record<string, unknown>): Promise<SqlJsStatic>;
}
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": [
"ES2022"
],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"sourceMap": true
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"dist",
"src/__tests__"
]
}
+82
View File
@@ -0,0 +1,82 @@
# noteapp — Desktop
> Electron desktop application wrapping the noteapp fullstack web project.
## Architecture
```
Web Fullstack Project
×
Desktop Shell (Electron)
=
Desktop Application
```
- **Main Process** — Window management, menu, tray, IPC, auto-update
- **Preload** — Secure bridge between main and renderer
- **Renderer** — Your existing web frontend (Next.js)
## Development
```bash
# Install dependencies
npm install
# Start dev mode (web + api + electron concurrently)
npm run dev
```
Dev mode:
- Web frontend: `http://localhost:3000`
- API backend: `http://localhost:3001`
- Electron loads the web URL directly
## Build
```bash
# Build for current platform
npm run build
# Platform-specific builds
npm run build:electron:mac
npm run build:electron:win
npm run build:electron:linux
```
Output: `release/` directory with platform installers.
## Project Structure
```
electron/
├── main.ts — Main process (BrowserWindow, Menu, lifecycle)
├── preload.ts — Secure IPC bridge (contextBridge)
├── ipc.ts — IPC handlers (dialogs, store, notifications)
├── tray.ts — System tray
├── updater.ts — Auto-update (electron-updater)
└── utils.ts — Shared utilities
```
## IPC API (available in renderer)
```typescript
window.electronAPI.getAppVersion()
window.electronAPI.getPlatform()
window.electronAPI.minimizeWindow()
window.electronAPI.maximizeWindow()
window.electronAPI.closeWindow()
window.electronAPI.openFile(options?)
window.electronAPI.saveFile(options?)
window.electronAPI.showNotification(title, body)
window.electronAPI.openExternal(url)
window.electronAPI.storeGet(key)
window.electronAPI.storeSet(key, value)
window.electronAPI.checkForUpdates()
window.electronAPI.downloadUpdate()
window.electronAPI.quitAndInstall()
```
## Auto Update
Configured via `electron-builder.yml`. Uses `electron-updater` with generic provider.
Set your release URL in the `publish` section.
Binary file not shown.

After

Width:  |  Height:  |  Size: 66 B

@@ -0,0 +1,100 @@
# electron-builder.yml — Auto-generated by SF-06
appId: com.noteapp.desktop
productName: noteapp
copyright: "Copyright © 2026 noteapp"
# ─── Directories ─────────────────────────────────
directories:
output: release
buildResources: assets
# ─── Files ───────────────────────────────────────
files:
- dist/electron/**/*
- "!node_modules/**/*"
- "**/*.node"
# ─── Extra Resources ─────────────────────────────
extraResources:
- from: "../web/out"
to: "web"
filter:
- "**/*"
- from: "../api/dist"
to: "api"
filter:
- "**/*"
- from: "../api/data"
to: "data"
filter:
- "**/*"
# ─── macOS ───────────────────────────────────────
mac:
category: public.app-category.productivity
icon: assets/icon.png
target:
- target: dmg
arch:
- x64
- arm64
- target: zip
arch:
- x64
- arm64
darkModeSupport: true
hardenedRuntime: true
gatekeeperAssess: false
entitlements: entitlements.mac.plist
entitlementsInherit: entitlements.mac.plist
# ─── Windows ─────────────────────────────────────
win:
icon: assets/icon.png
target:
- target: nsis
arch:
- x64
- target: portable
arch:
- x64
publisherName: noteapp
nsis:
oneClick: false
perMachine: false
allowToChangeInstallationDirectory: true
installerIcon: assets/icon.ico
uninstallerIcon: assets/icon.ico
installerHeaderIcon: assets/icon.ico
createDesktopShortcut: true
createStartMenuShortcut: true
shortcutName: noteapp
# ─── Linux ───────────────────────────────────────
linux:
icon: assets/icon.png
category: Utility
target:
- target: AppImage
arch:
- x64
- target: deb
arch:
- x64
- target: rpm
arch:
- x64
desktop:
Name: noteapp
Comment: noteapp Desktop Application
Terminal: false
Type: Application
Categories: Utility;
# ─── Auto Update ─────────────────────────────────
publish:
provider: generic
url: https://releases.example.com/noteapp/
channel: latest
+77
View File
@@ -0,0 +1,77 @@
import { ipcMain, app, dialog, shell, BrowserWindow, Notification } from "electron";
import Store from "electron-store";
const store = new Store() as any;
export function setupIPC(mainWindow: BrowserWindow): void {
// ── App Info ──
ipcMain.handle("app:version", () => app.getVersion());
ipcMain.handle("app:platform", () => process.platform);
ipcMain.handle("app:isDev", () => !app.isPackaged);
// ── Window Controls ──
ipcMain.on("window:minimize", () => {
mainWindow?.minimize();
});
ipcMain.on("window:maximize", () => {
if (mainWindow?.isMaximized()) {
mainWindow.unmaximize();
} else {
mainWindow?.maximize();
}
});
ipcMain.on("window:close", () => {
mainWindow?.close();
});
ipcMain.handle("window:isMaximized", () => {
return mainWindow?.isMaximized() ?? false;
});
// ── File Dialogs ──
ipcMain.handle("dialog:openFile", async (_event, options?) => {
const result = await dialog.showOpenDialog(mainWindow, {
properties: ["openFile"],
...options,
});
return result.canceled ? undefined : result.filePaths;
});
ipcMain.handle("dialog:saveFile", async (_event, options?) => {
const result = await dialog.showSaveDialog(mainWindow, {
...options,
});
return result.canceled ? undefined : result.filePath;
});
// ── Notifications ──
ipcMain.on("notification:show", (_event, { title, body }) => {
if (Notification.isSupported()) {
new Notification({ title, body }).show();
}
});
// ── Shell ──
ipcMain.on("shell:openExternal", (_event, url: string) => {
shell.openExternal(url);
});
ipcMain.on("shell:openPath", (_event, path: string) => {
shell.openPath(path);
});
// ── Persistent Store ──
ipcMain.handle("store:get", (_event, key: string) => {
return store.get(key);
});
ipcMain.handle("store:set", (_event, key: string, value: unknown) => {
store.set(key, value);
});
ipcMain.handle("store:delete", (_event, key: string) => {
store.delete(key);
});
}
+238
View File
@@ -0,0 +1,238 @@
import { app, BrowserWindow, Menu, nativeImage, ipcMain } from "electron";
import { join } from "path";
import { existsSync } from "fs";
import { setupTray } from "./tray";
import { setupIPC } from "./ipc";
import { setupUpdater } from "./updater";
import { checkSingleInstance, getWebUrl, getApiPath, isDev } from "./utils";
// ─── Constants ───────────────────────────────────
const WEB_PORT = 3000;
const API_PORT = 3001;
const APP_NAME = "noteapp";
// ─── Single Instance Lock ────────────────────────
if (!checkSingleInstance()) {
app.quit();
process.exit(0);
}
// ─── State ───────────────────────────────────────
let mainWindow: BrowserWindow | null = null;
let apiProcess: ReturnType<typeof import("child_process").spawn> | null = null;
// ─── Create Window ───────────────────────────────
function createWindow(): BrowserWindow {
const preloadPath = join(__dirname, "preload.js");
const win = new BrowserWindow({
title: APP_NAME,
width: 1400,
height: 900,
minWidth: 800,
minHeight: 600,
show: false,
icon: getIconPath(),
webPreferences: {
preload: preloadPath,
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
},
titleBarStyle: process.platform === "darwin" ? "hiddenInset" : "default",
trafficLightPosition: { x: 16, y: 16 },
});
// ── Load content ──
const url = getWebUrl(isDev(), WEB_PORT);
if (url.startsWith("http")) {
win.loadURL(url);
} else {
win.loadFile(url);
}
// ── Show when ready ──
win.once("ready-to-show", () => {
win.show();
if (isDev()) {
win.webContents.openDevTools({ mode: "detach" });
}
});
// ── External links → default browser ──
win.webContents.setWindowOpenHandler(({ url }) => {
if (url.startsWith("http")) {
require("electron").shell.openExternal(url);
}
return { action: "deny" };
});
// ── Prevent navigation away ──
win.webContents.on("will-navigate", (event, url) => {
const allowed = isDev()
? url.startsWith("http://localhost:3000")
: url.startsWith("file://");
if (!allowed) event.preventDefault();
});
return win;
}
// ─── Icon Path ───────────────────────────────────
function getIconPath(): string {
const candidates = [
join(__dirname, "..", "assets", "icon.png"),
join(__dirname, "..", "assets", "icon.icns"),
join(__dirname, "..", "assets", "icon.ico"),
];
for (const p of candidates) {
if (existsSync(p)) return p;
}
return "";
}
// ─── API Server (Production) ─────────────────────
function startApiServer(): void {
if (isDev()) return;
const apiPath = getApiPath();
if (!apiPath || !existsSync(apiPath)) {
console.warn("[main] API server not found at", apiPath);
return;
}
const { spawn } = require("child_process");
apiProcess = spawn("node", [apiPath], {
env: {
...process.env,
NODE_ENV: "production",
PORT: String(API_PORT),
HOST: "127.0.0.1",
},
stdio: ["ignore", "pipe", "pipe"],
});
apiProcess!.stdout?.on("data", (data: Buffer) => {
console.log("[api]", data.toString().trim());
});
apiProcess!.stderr?.on("data", (data: Buffer) => {
console.error("[api]", data.toString().trim());
});
apiProcess!.on("exit", (code: number) => {
console.warn("[main] API server exited with code", code);
apiProcess = null;
});
}
function stopApiServer(): void {
if (apiProcess) {
apiProcess.kill("SIGTERM");
apiProcess = null;
}
}
// ─── Menu ────────────────────────────────────────
function buildMenu(): void {
const isMac = process.platform === "darwin";
const template: Electron.MenuItemConstructorOptions[] = [
...(isMac
? [{
label: APP_NAME,
submenu: [
{ role: "about" as const },
{ type: "separator" as const },
{ role: "services" as const },
{ type: "separator" as const },
{ role: "hide" as const },
{ role: "hideOthers" as const },
{ role: "unhide" as const },
{ type: "separator" as const },
{ role: "quit" as const },
],
}]
: []),
{
label: "Edit",
submenu: [
{ role: "undo" },
{ role: "redo" },
{ type: "separator" },
{ role: "cut" },
{ role: "copy" },
{ role: "paste" },
{ role: "selectAll" },
],
},
{
label: "View",
submenu: [
{ role: "reload" },
{ role: "forceReload" },
{ role: "toggleDevTools" },
{ type: "separator" },
{ role: "resetZoom" },
{ role: "zoomIn" },
{ role: "zoomOut" },
{ type: "separator" },
{ role: "togglefullscreen" },
],
},
{
label: "Window",
submenu: [
{ role: "minimize" },
{ role: "zoom" },
...(isMac
? [
{ type: "separator" as const },
{ role: "front" as const },
]
: [{ role: "close" as const }]),
],
},
];
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
}
// ─── App Lifecycle ───────────────────────────────
app.whenReady().then(() => {
startApiServer();
mainWindow = createWindow();
buildMenu();
if (mainWindow) {
setupTray(mainWindow, APP_NAME);
setupIPC(mainWindow);
}
setupUpdater(APP_NAME);
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
mainWindow = createWindow();
if (mainWindow) {
setupTray(mainWindow, APP_NAME);
setupIPC(mainWindow);
}
}
});
});
app.on("window-all-closed", () => {
stopApiServer();
if (process.platform !== "darwin") {
app.quit();
}
});
app.on("before-quit", () => {
stopApiServer();
});
app.on("gpu-process-crashed" as any, () => {
console.warn("[main] GPU process crashed — continuing");
});
process.on("exit", () => {
stopApiServer();
});
@@ -0,0 +1,92 @@
import { contextBridge, ipcRenderer } from "electron";
// ─── Expose safe API to renderer ─────────────────
contextBridge.exposeInMainWorld("electronAPI", {
// ── App Info ──
getAppVersion: () => ipcRenderer.invoke("app:version"),
getPlatform: () => ipcRenderer.invoke("app:platform"),
getIsDev: () => ipcRenderer.invoke("app:isDev"),
// ── Window Controls ──
minimizeWindow: () => ipcRenderer.send("window:minimize"),
maximizeWindow: () => ipcRenderer.send("window:maximize"),
closeWindow: () => ipcRenderer.send("window:close"),
isMaximized: () => ipcRenderer.invoke("window:isMaximized"),
// ── File Dialogs ──
openFile: (options?: Electron.OpenDialogOptions) =>
ipcRenderer.invoke("dialog:openFile", options),
saveFile: (options?: Electron.SaveDialogOptions) =>
ipcRenderer.invoke("dialog:saveFile", options),
// ── Notifications ──
showNotification: (title: string, body: string) =>
ipcRenderer.send("notification:show", { title, body }),
// ── Shell ──
openExternal: (url: string) => ipcRenderer.send("shell:openExternal", url),
openPath: (path: string) => ipcRenderer.send("shell:openPath", path),
// ── Store ──
storeGet: (key: string) => ipcRenderer.invoke("store:get", key),
storeSet: (key: string, value: unknown) =>
ipcRenderer.invoke("store:set", key, value),
storeDelete: (key: string) => ipcRenderer.invoke("store:delete", key),
// ── Updates ──
checkForUpdates: () => ipcRenderer.invoke("updater:check"),
downloadUpdate: () => ipcRenderer.invoke("updater:download"),
quitAndInstall: () => ipcRenderer.send("updater:quitAndInstall"),
// ── Update Events ──
onUpdateAvailable: (callback: (info: unknown) => void) => {
ipcRenderer.on("updater:available", (_event, info) => callback(info));
},
onUpdateDownloaded: (callback: (info: unknown) => void) => {
ipcRenderer.on("updater:downloaded", (_event, info) => callback(info));
},
onUpdateProgress: (callback: (progress: unknown) => void) => {
ipcRenderer.on("updater:progress", (_event, progress) => callback(progress));
},
onUpdateError: (callback: (error: string) => void) => {
ipcRenderer.on("updater:error", (_event, error) => callback(error));
},
// ── Remove listener ──
removeAllListeners: (channel: string) => {
ipcRenderer.removeAllListeners(channel);
},
});
// ─── Type declarations for renderer ──────────────
export interface ElectronAPI {
getAppVersion: () => Promise<string>;
getPlatform: () => Promise<string>;
getIsDev: () => Promise<boolean>;
minimizeWindow: () => void;
maximizeWindow: () => void;
closeWindow: () => void;
isMaximized: () => Promise<boolean>;
openFile: (options?: Electron.OpenDialogOptions) => Promise<string[] | undefined>;
saveFile: (options?: Electron.SaveDialogOptions) => Promise<string | undefined>;
showNotification: (title: string, body: string) => void;
openExternal: (url: string) => void;
openPath: (path: string) => void;
storeGet: (key: string) => Promise<unknown>;
storeSet: (key: string, value: unknown) => Promise<void>;
storeDelete: (key: string) => Promise<void>;
checkForUpdates: () => Promise<void>;
downloadUpdate: () => Promise<void>;
quitAndInstall: () => void;
onUpdateAvailable: (callback: (info: unknown) => void) => void;
onUpdateDownloaded: (callback: (info: unknown) => void) => void;
onUpdateProgress: (callback: (progress: unknown) => void) => void;
onUpdateError: (callback: (error: string) => void) => void;
removeAllListeners: (channel: string) => void;
}
declare global {
interface Window {
electronAPI: ElectronAPI;
}
}
+61
View File
@@ -0,0 +1,61 @@
import { Tray, Menu, nativeImage, BrowserWindow, app } from "electron";
import { join } from "path";
import { existsSync } from "fs";
let tray: Tray | null = null;
export function setupTray(mainWindow: BrowserWindow, appName: string): void {
const iconPath = getTrayIcon();
if (!iconPath) {
console.warn("[tray] No icon found, skipping tray setup");
return;
}
const icon = nativeImage.createFromPath(iconPath);
tray = new Tray(icon.resize({ width: 16, height: 16 }));
tray.setToolTip(appName);
const contextMenu = Menu.buildFromTemplate([
{
label: "Show " + appName,
click: () => {
mainWindow.show();
mainWindow.focus();
},
},
{ type: "separator" },
{
label: "Quit",
click: () => {
app.quit();
},
},
]);
tray.setContextMenu(contextMenu);
tray.on("click", () => {
if (mainWindow.isVisible()) {
mainWindow.focus();
} else {
mainWindow.show();
}
});
tray.on("double-click", () => {
mainWindow.show();
mainWindow.focus();
});
}
function getTrayIcon(): string | null {
const candidates = [
join(__dirname, "..", "assets", "tray-icon.png"),
join(__dirname, "..", "assets", "icon.png"),
join(__dirname, "..", "assets", "icon.ico"),
];
for (const p of candidates) {
if (existsSync(p)) return p;
}
return null;
}
@@ -0,0 +1,87 @@
import { autoUpdater } from "electron-updater";
import { ipcMain, BrowserWindow } from "electron";
let updateAvailable = false;
let updateDownloaded = false;
export function setupUpdater(appName: string): void {
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.logger = {
info: (msg) => console.log("[updater]", msg),
warn: (msg) => console.warn("[updater]", msg),
error: (msg) => console.error("[updater]", msg),
debug: (msg) => console.debug("[updater]", msg),
};
autoUpdater.on("checking-for-update", () => {
console.log("[updater] Checking for updates...");
});
autoUpdater.on("update-available", (info) => {
updateAvailable = true;
console.log("[updater] Update available:", info.version);
broadcast("updater:available", info);
});
autoUpdater.on("update-not-available", (info) => {
console.log("[updater] Up to date:", info.version);
});
autoUpdater.on("download-progress", (progress) => {
broadcast("updater:progress", {
percent: progress.percent,
bytesPerSecond: progress.bytesPerSecond,
transferred: progress.transferred,
total: progress.total,
});
});
autoUpdater.on("update-downloaded", (info) => {
updateDownloaded = true;
console.log("[updater] Update downloaded:", info.version);
broadcast("updater:downloaded", info);
});
autoUpdater.on("error", (err) => {
console.error("[updater] Error:", err.message);
broadcast("updater:error", err.message);
});
ipcMain.handle("updater:check", async () => {
try {
const result = await autoUpdater.checkForUpdates();
return result?.updateInfo ?? null;
} catch (err) {
console.error("[updater] Check failed:", err);
return null;
}
});
ipcMain.handle("updater:download", async () => {
if (!updateAvailable) return;
try {
await autoUpdater.downloadUpdate();
} catch (err) {
console.error("[updater] Download failed:", err);
}
});
ipcMain.on("updater:quitAndInstall", () => {
if (updateDownloaded) {
autoUpdater.quitAndInstall(false, true);
}
});
setTimeout(() => {
autoUpdater.checkForUpdates().catch(() => {});
}, 10_000);
}
function broadcast(channel: string, data: unknown): void {
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) {
win.webContents.send(channel, data);
}
}
}
+80
View File
@@ -0,0 +1,80 @@
import { app } from "electron";
import { join } from "path";
import { existsSync } from "fs";
// ─── Single Instance Lock ────────────────────────
export function checkSingleInstance(): boolean {
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
app.quit();
return false;
}
app.on("second-instance", (_event, _argv, _cwd) => {
const windows = require("electron").BrowserWindow.getAllWindows();
if (windows.length > 0) {
const win = windows[0];
if (win.isMinimized()) win.restore();
win.focus();
}
});
return true;
}
// ─── Dev mode detection ──────────────────────────
export function isDev(): boolean {
return !app.isPackaged;
}
// ─── Web URL ─────────────────────────────────────
export function getWebUrl(dev: boolean, port: number): string {
if (dev) {
return "http://localhost:" + port;
}
const appPath = app.isPackaged
? join(process.resourcesPath, "web")
: join(__dirname, "..", "..", "web", "out");
const staticIndex = join(appPath, "index.html");
if (existsSync(staticIndex)) {
return staticIndex;
}
const standalone = join(appPath, ".next", "standalone", "server.js");
if (existsSync(standalone)) {
return "http://localhost:3000";
}
const fallback = join(appPath, "index.html");
if (existsSync(fallback)) {
return fallback;
}
console.warn("[utils] No web build found at", appPath);
return "http://localhost:3000";
}
// ─── API Path ────────────────────────────────────
export function getApiPath(): string | null {
if (app.isPackaged) {
const packed = join(process.resourcesPath, "api", "dist", "index.js");
if (existsSync(packed)) return packed;
const packedSrc = join(process.resourcesPath, "api", "src", "index.js");
if (existsSync(packedSrc)) return packedSrc;
}
const devPath = join(__dirname, "..", "..", "api", "dist", "index.js");
if (existsSync(devPath)) return devPath;
const devSrc = join(__dirname, "..", "..", "api", "src", "index.js");
if (existsSync(devSrc)) return devSrc;
return null;
}
// ─── Get API port from env ───────────────────────
export function getApiPort(): number {
return 3001;
}
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
</dict>
</plist>
+41
View File
@@ -0,0 +1,41 @@
{
"name": "noteapp-desktop",
"version": "1.0.0",
"private": true,
"description": "noteapp — Desktop Application powered by Electron",
"main": "dist/electron/main.js",
"scripts": {
"dev": "concurrently \"npm run dev:web\" \"npm run dev:api\" \"npm run dev:electron\"",
"dev:electron": "tsc && electron .",
"dev:web": "cd ../web && npm run dev",
"dev:api": "cd ../api && npm run dev",
"build": "npm run build:web && npm run build:api && npm run build:electron",
"build:web": "cd ../web && npm run build",
"build:api": "cd ../api && npm run build",
"build:electron": "tsc && electron-builder --config electron-builder.yml",
"build:electron:win": "tsc && electron-builder --win --config electron-builder.yml",
"build:electron:mac": "tsc && electron-builder --mac --config electron-builder.yml",
"build:electron:linux": "tsc && electron-builder --linux --config electron-builder.yml",
"pack": "tsc && electron-builder --dir --config electron-builder.yml",
"typecheck": "tsc --noEmit",
"lint": "eslint electron/"
},
"dependencies": {
"electron-updater": "^6.3.9",
"electron-store": "^10.0.0"
},
"devDependencies": {
"electron": "^35.0.0",
"electron-builder": "^25.1.8",
"concurrently": "^9.1.0",
"typescript": "^5.7.0",
"@types/node": "^22.10.0"
},
"build": {
"productName": "noteapp",
"appId": "com.noteapp.desktop",
"directories": {
"output": "release"
}
}
}
+32
View File
@@ -0,0 +1,32 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"moduleResolution": "node",
"lib": [
"ES2022"
],
"outDir": "dist",
"rootDir": "electron",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": false,
"sourceMap": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"types": [
"node"
]
},
"include": [
"electron/**/*.ts"
],
"exclude": [
"node_modules",
"dist",
"release"
]
}
@@ -0,0 +1,51 @@
"use client";
import { useState } from "react";
import { Card } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { EmptyState } from "@/components/ui/EmptyState";
import { useCustomers } from "@/hooks/useCustomers";
export default function PagePage() {
const { data, loading, error, refetch } = useCustomers();
const [open, setOpen] = useState(false);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-gray-800"></h1>
<p className="text-sm text-gray-500 mt-1"></p>
</div>
<Button onClick={() => setOpen(true)} variant="primary" size="sm"></Button>
</div>
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
</div>
) : error ? (
<Card className="text-center py-8">
<p className="text-red-500 text-sm">{error}</p>
<Button onClick={refetch} variant="secondary" size="sm" className="mt-2"></Button>
</Card>
) : data.length === 0 ? (
<EmptyState
icon="📭"
title="暂无数据"
description="还没有任何记录,点击上方按钮开始"
action={<Button onClick={() => setOpen(true)} variant="primary" size="sm"></Button>}
/>
) : (
<div className="space-y-3">
{data.map((item: any) => (
<Card key={item.id}>
<h3 className="font-medium text-gray-800">{item.title || item.name || `Item ${item.id.slice(0, 8)}`}</h3>
<p className="text-xs text-gray-400 mt-1">{item.createdAt || ""}</p>
</Card>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,51 @@
"use client";
import { useState } from "react";
import { Card } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { EmptyState } from "@/components/ui/EmptyState";
import { useDashboard } from "@/hooks/useDashboard";
export default function PagePage() {
const { data, loading, error, refetch } = useDashboard();
const [open, setOpen] = useState(false);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-gray-800"></h1>
<p className="text-sm text-gray-500 mt-1"></p>
</div>
<Button onClick={() => setOpen(true)} variant="primary" size="sm"></Button>
</div>
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
</div>
) : error ? (
<Card className="text-center py-8">
<p className="text-red-500 text-sm">{error}</p>
<Button onClick={refetch} variant="secondary" size="sm" className="mt-2"></Button>
</Card>
) : data.length === 0 ? (
<EmptyState
icon="📭"
title="暂无数据"
description="还没有任何记录,点击上方按钮开始"
action={<Button onClick={() => setOpen(true)} variant="primary" size="sm"></Button>}
/>
) : (
<div className="space-y-3">
{data.map((item: any) => (
<Card key={item.id}>
<h3 className="font-medium text-gray-800">{item.title || item.name || `Item ${item.id.slice(0, 8)}`}</h3>
<p className="text-xs text-gray-400 mt-1">{item.createdAt || ""}</p>
</Card>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,51 @@
"use client";
import { useState } from "react";
import { Card } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { EmptyState } from "@/components/ui/EmptyState";
import { useFollow_ups } from "@/hooks/useFollow_ups";
export default function PagePage() {
const { data, loading, error, refetch } = useFollow_ups();
const [open, setOpen] = useState(false);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-gray-800"></h1>
<p className="text-sm text-gray-500 mt-1"></p>
</div>
<Button onClick={() => setOpen(true)} variant="primary" size="sm"></Button>
</div>
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
</div>
) : error ? (
<Card className="text-center py-8">
<p className="text-red-500 text-sm">{error}</p>
<Button onClick={refetch} variant="secondary" size="sm" className="mt-2"></Button>
</Card>
) : data.length === 0 ? (
<EmptyState
icon="📭"
title="暂无数据"
description="还没有任何记录,点击上方按钮开始"
action={<Button onClick={() => setOpen(true)} variant="primary" size="sm"></Button>}
/>
) : (
<div className="space-y-3">
{data.map((item: any) => (
<Card key={item.id}>
<h3 className="font-medium text-gray-800">{item.title || item.name || `Item ${item.id.slice(0, 8)}`}</h3>
<p className="text-xs text-gray-400 mt-1">{item.createdAt || ""}</p>
</Card>
))}
</div>
)}
</div>
);
}
+8
View File
@@ -0,0 +1,8 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
+30
View File
@@ -0,0 +1,30 @@
import type { Metadata } from "next";
import "./globals.css";
import { Sidebar } from "@/components/layout/Sidebar";
import { BottomNav } from "@/components/layout/BottomNav";
export const metadata: Metadata = {
title: "NoteApp",
description: "一款支持多端同步、Markdown 编辑和标签管理的笔记应用。",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-CN">
<body className="bg-gray-50 min-h-screen">
<div className="flex">
{/* Desktop Sidebar */}
<Sidebar items={[{"name":"首页","href":"/home","icon":"🏠"},{"name":"客户管理","href":"/customers","icon":"📋"},{"name":"销售漏斗","href":"/sales_pipeline","icon":"📅"},{"name":"跟进记录","href":"/follow_ups","icon":"✏️"},{"name":"数据分析仪表盘","href":"/dashboard","icon":"📸"}] } projectName="NoteApp" />
{/* Main Content */}
<main className="flex-1 lg:pl-64 pb-20 lg:pb-0">
<div className="max-w-2xl mx-auto px-4 py-6">
{children}
</div>
</main>
</div>
{/* Mobile Bottom Nav */}
<BottomNav items={[{"name":"首页","href":"/home","icon":"🏠"},{"name":"客户管理","href":"/customers","icon":"📋"},{"name":"销售漏斗","href":"/sales_pipeline","icon":"📅"},{"name":"跟进记录","href":"/follow_ups","icon":"✏️"}] } />
</body>
</html>
);
}
@@ -0,0 +1,25 @@
"use client";
import { useState } from "react";
import { Input } from "@/components/ui/Input";
import { Button } from "@/components/ui/Button";
export default function NoteEditorPage() {
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-gray-800"></h1>
<Button variant="primary" size="sm"></Button>
</div>
<Input value={title} onChange={e => setTitle(e.target.value)} placeholder="笔记标题" className="text-lg font-medium border-0 px-0" />
<textarea
value={content}
onChange={e => setContent(e.target.value)}
placeholder="开始记录..."
className="w-full min-h-[300px] text-sm text-gray-700 resize-none focus:outline-none"
/>
</div>
);
}
@@ -0,0 +1,25 @@
"use client";
import { useState } from "react";
import { Input } from "@/components/ui/Input";
import { Button } from "@/components/ui/Button";
export default function NoteEditorPage() {
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-gray-800"></h1>
<Button variant="primary" size="sm"></Button>
</div>
<Input value={title} onChange={e => setTitle(e.target.value)} placeholder="笔记标题" className="text-lg font-medium border-0 px-0" />
<textarea
value={content}
onChange={e => setContent(e.target.value)}
placeholder="开始记录..."
className="w-full min-h-[300px] text-sm text-gray-700 resize-none focus:outline-none"
/>
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
import Link from "next/link";
export default function HomePage() {
return (
<div className="space-y-6">
{/* Hero Section */}
<section className="bg-gradient-to-br from-blue-500 to-blue-700 rounded-2xl p-6 text-white">
<h1 className="text-2xl font-bold">NoteApp</h1>
<p className="mt-2 text-blue-100 text-sm"></p>
</section>
{/* Quick Actions */}
<section>
<h2 className="text-lg font-semibold text-gray-800 mb-3"></h2>
<div className="grid grid-cols-2 gap-3">
<Link key="0" href="/home" className="bg-white rounded-xl border border-gray-100 p-4 text-center hover:shadow-md transition-shadow">
<span className="text-2xl block mb-1">📋</span>
<span className="text-sm text-gray-600"></span>
</Link>
<Link key="1" href="/home" className="bg-white rounded-xl border border-gray-100 p-4 text-center hover:shadow-md transition-shadow">
<span className="text-2xl block mb-1">📅</span>
<span className="text-sm text-gray-600"></span>
</Link>
<Link key="2" href="/home" className="bg-white rounded-xl border border-gray-100 p-4 text-center hover:shadow-md transition-shadow">
<span className="text-2xl block mb-1">📝</span>
<span className="text-sm text-gray-600"></span>
</Link>
<Link key="3" href="/home" className="bg-white rounded-xl border border-gray-100 p-4 text-center hover:shadow-md transition-shadow">
<span className="text-2xl block mb-1">📸</span>
<span className="text-sm text-gray-600"></span>
</Link>
</div>
</section>
{/* Recent Activity / Stats */}
<section>
<h2 className="text-lg font-semibold text-gray-800 mb-3"></h2>
<div className="bg-white rounded-xl border border-gray-100 p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500">使 NoteApp</p>
<p className="text-xs text-gray-400 mt-1"></p>
</div>
<span className="text-3xl">👋</span>
</div>
</div>
</section>
</div>
);
}
@@ -0,0 +1,51 @@
"use client";
import { useState } from "react";
import { Card } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { EmptyState } from "@/components/ui/EmptyState";
import { useSales_pipeline } from "@/hooks/useSales_pipeline";
export default function PagePage() {
const { data, loading, error, refetch } = useSales_pipeline();
const [open, setOpen] = useState(false);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-gray-800"></h1>
<p className="text-sm text-gray-500 mt-1"></p>
</div>
<Button onClick={() => setOpen(true)} variant="primary" size="sm"></Button>
</div>
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
</div>
) : error ? (
<Card className="text-center py-8">
<p className="text-red-500 text-sm">{error}</p>
<Button onClick={refetch} variant="secondary" size="sm" className="mt-2"></Button>
</Card>
) : data.length === 0 ? (
<EmptyState
icon="📭"
title="暂无数据"
description="还没有任何记录,点击上方按钮开始"
action={<Button onClick={() => setOpen(true)} variant="primary" size="sm"></Button>}
/>
) : (
<div className="space-y-3">
{data.map((item: any) => (
<Card key={item.id}>
<h3 className="font-medium text-gray-800">{item.title || item.name || `Item ${item.id.slice(0, 8)}`}</h3>
<p className="text-xs text-gray-400 mt-1">{item.createdAt || ""}</p>
</Card>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,14 @@
"use client";
import { Card } from "@/components/ui/Card";
const settings = ["账户安全", "通知设置", "主题偏好", "数据管理", "关于应用"];
export default function SettingsPage() {
return (
<div className="space-y-4">
<h1 className="text-xl font-bold text-gray-800"></h1>
<Card className="divide-y p-0">
{settings.map((s, i) => <div key={i} className="px-4 py-3 text-sm text-gray-700 hover:bg-gray-50 cursor-pointer flex items-center justify-between">{s} <span className="text-gray-300"></span></div>)}
</Card>
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
"use client";
import { Card } from "@/components/ui/Card";
const tags = ["工作", "个人", "灵感", "读书笔记", "代码片段", "日记"];
export default function TagPage() {
return (
<div className="space-y-4">
<h1 className="text-xl font-bold text-gray-800"></h1>
<div className="flex flex-wrap gap-2">
{tags.map(tag => <span key={tag} className="px-3 py-1.5 bg-gray-100 rounded-full text-sm text-gray-600 cursor-pointer hover:bg-gray-200">{tag}</span>)}
</div>
</div>
);
}
@@ -0,0 +1,51 @@
"use client";
import { useState } from "react";
import { Card } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { EmptyState } from "@/components/ui/EmptyState";
import { use客户管理 } from "@/hooks/use客户管理";
export default function PagePage() {
const { data, loading, error, refetch } = use客户管理();
const [open, setOpen] = useState(false);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-gray-800"></h1>
<p className="text-sm text-gray-500 mt-1"></p>
</div>
<Button onClick={() => setOpen(true)} variant="primary" size="sm"></Button>
</div>
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
</div>
) : error ? (
<Card className="text-center py-8">
<p className="text-red-500 text-sm">{error}</p>
<Button onClick={refetch} variant="secondary" size="sm" className="mt-2"></Button>
</Card>
) : data.length === 0 ? (
<EmptyState
icon="📭"
title="暂无数据"
description="还没有任何记录,点击上方按钮开始"
action={<Button onClick={() => setOpen(true)} variant="primary" size="sm"></Button>}
/>
) : (
<div className="space-y-3">
{data.map((item: any) => (
<Card key={item.id}>
<h3 className="font-medium text-gray-800">{item.title || item.name || `Item ${item.id.slice(0, 8)}`}</h3>
<p className="text-xs text-gray-400 mt-1">{item.createdAt || ""}</p>
</Card>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,51 @@
"use client";
import { useState } from "react";
import { Card } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { EmptyState } from "@/components/ui/EmptyState";
import { use数据分析仪表盘 } from "@/hooks/use数据分析仪表盘";
export default function PagePage() {
const { data, loading, error, refetch } = use数据分析仪表盘();
const [open, setOpen] = useState(false);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-gray-800"></h1>
<p className="text-sm text-gray-500 mt-1"></p>
</div>
<Button onClick={() => setOpen(true)} variant="primary" size="sm"></Button>
</div>
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
</div>
) : error ? (
<Card className="text-center py-8">
<p className="text-red-500 text-sm">{error}</p>
<Button onClick={refetch} variant="secondary" size="sm" className="mt-2"></Button>
</Card>
) : data.length === 0 ? (
<EmptyState
icon="📭"
title="暂无数据"
description="还没有任何记录,点击上方按钮开始"
action={<Button onClick={() => setOpen(true)} variant="primary" size="sm"></Button>}
/>
) : (
<div className="space-y-3">
{data.map((item: any) => (
<Card key={item.id}>
<h3 className="font-medium text-gray-800">{item.title || item.name || `Item ${item.id.slice(0, 8)}`}</h3>
<p className="text-xs text-gray-400 mt-1">{item.createdAt || ""}</p>
</Card>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,51 @@
"use client";
import { useState } from "react";
import { Card } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { EmptyState } from "@/components/ui/EmptyState";
import { use跟进记录 } from "@/hooks/use跟进记录";
export default function PagePage() {
const { data, loading, error, refetch } = use跟进记录();
const [open, setOpen] = useState(false);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-gray-800"></h1>
<p className="text-sm text-gray-500 mt-1"></p>
</div>
<Button onClick={() => setOpen(true)} variant="primary" size="sm"></Button>
</div>
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
</div>
) : error ? (
<Card className="text-center py-8">
<p className="text-red-500 text-sm">{error}</p>
<Button onClick={refetch} variant="secondary" size="sm" className="mt-2"></Button>
</Card>
) : data.length === 0 ? (
<EmptyState
icon="📭"
title="暂无数据"
description="还没有任何记录,点击上方按钮开始"
action={<Button onClick={() => setOpen(true)} variant="primary" size="sm"></Button>}
/>
) : (
<div className="space-y-3">
{data.map((item: any) => (
<Card key={item.id}>
<h3 className="font-medium text-gray-800">{item.title || item.name || `Item ${item.id.slice(0, 8)}`}</h3>
<p className="text-xs text-gray-400 mt-1">{item.createdAt || ""}</p>
</Card>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,51 @@
"use client";
import { useState } from "react";
import { Card } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { EmptyState } from "@/components/ui/EmptyState";
import { use销售漏斗 } from "@/hooks/use销售漏斗";
export default function PagePage() {
const { data, loading, error, refetch } = use销售漏斗();
const [open, setOpen] = useState(false);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-gray-800"></h1>
<p className="text-sm text-gray-500 mt-1"></p>
</div>
<Button onClick={() => setOpen(true)} variant="primary" size="sm"></Button>
</div>
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
</div>
) : error ? (
<Card className="text-center py-8">
<p className="text-red-500 text-sm">{error}</p>
<Button onClick={refetch} variant="secondary" size="sm" className="mt-2"></Button>
</Card>
) : data.length === 0 ? (
<EmptyState
icon="📭"
title="暂无数据"
description="还没有任何记录,点击上方按钮开始"
action={<Button onClick={() => setOpen(true)} variant="primary" size="sm"></Button>}
/>
) : (
<div className="space-y-3">
{data.map((item: any) => (
<Card key={item.id}>
<h3 className="font-medium text-gray-800">{item.title || item.name || `Item ${item.id.slice(0, 8)}`}</h3>
<p className="text-xs text-gray-400 mt-1">{item.createdAt || ""}</p>
</Card>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,38 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
interface NavItem {
name: string;
href: string;
icon: string;
}
export function BottomNav({ items }: { items: NavItem[] }) {
const pathname = usePathname();
if (!items.length) return null;
return (
<nav className="lg:hidden fixed bottom-0 inset-x-0 bg-white border-t border-gray-100 z-40">
<div className="flex items-center justify-around h-16">
{items.map((item) => {
const active = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={`flex flex-col items-center gap-0.5 px-3 py-1 ${
active ? "text-blue-600" : "text-gray-400"
}`}
>
<span className="text-xl">{item.icon}</span>
<span className="text-[10px]">{item.name}</span>
</Link>
);
})}
</div>
</nav>
);
}
@@ -0,0 +1,44 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
interface NavItem {
name: string;
href: string;
icon: string;
}
interface SidebarProps {
items: NavItem[];
projectName: string;
}
export function Sidebar({ items, projectName }: SidebarProps) {
const pathname = usePathname();
return (
<aside className="hidden lg:flex lg:flex-col lg:w-64 lg:fixed lg:inset-y-0 bg-white border-r border-gray-100">
<div className="px-6 py-5 border-b border-gray-50">
<h1 className="text-lg font-bold text-gray-800">{projectName}</h1>
</div>
<nav className="flex-1 px-3 py-4 space-y-1 overflow-y-auto">
{items.map((item) => {
const active = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors ${
active ? "bg-blue-50 text-blue-700 font-medium" : "text-gray-600 hover:bg-gray-50"
}`}
>
<span className="text-lg">{item.icon}</span>
<span>{item.name}</span>
</Link>
);
})}
</nav>
</aside>
);
}
@@ -0,0 +1,20 @@
import type { Note } from "@/types";
import { Card } from "@/components/ui/Card";
interface NoteCardProps {
note: Note;
onClick?: () => void;
}
export function NoteCard({ note, onClick }: NoteCardProps) {
return (
<Card onClick={onClick}>
<h3 className="font-medium text-gray-800 truncate">{note.title || "无标题"}</h3>
<p className="text-sm text-gray-500 mt-1 line-clamp-2">{note.content?.slice(0, 120) || "空笔记"}</p>
<div className="flex items-center gap-2 mt-2">
{note.isMarkdown && <span className="text-xs px-1.5 py-0.5 bg-purple-100 text-purple-600 rounded">MD</span>}
<span className="text-xs text-gray-400">{new Date(note.updatedAt).toLocaleDateString("zh-CN")}</span>
</div>
</Card>
);
}
@@ -0,0 +1,31 @@
"use client";
import { type ButtonHTMLAttributes } from "react";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "danger" | "ghost";
size?: "sm" | "md" | "lg";
loading?: boolean;
}
export function Button({ variant = "primary", size = "md", loading, children, className = "", disabled, ...props }: ButtonProps) {
const base = "inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed";
const variants = {
primary: "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500",
secondary: "bg-gray-100 text-gray-700 hover:bg-gray-200 focus:ring-gray-400",
danger: "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500",
ghost: "text-gray-600 hover:bg-gray-100 focus:ring-gray-400",
};
const sizes = {
sm: "px-3 py-1.5 text-sm",
md: "px-4 py-2 text-sm",
lg: "px-6 py-3 text-base",
};
return (
<button className={`${base} ${variants[variant]} ${sizes[size]} ${className}`} disabled={disabled || loading} {...props}>
{loading && <svg className="animate-spin -ml-1 mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>}
{children}
</button>
);
}
@@ -0,0 +1,18 @@
import type { ReactNode } from "react";
interface CardProps {
children: ReactNode;
className?: string;
onClick?: () => void;
}
export function Card({ children, className = "", onClick }: CardProps) {
return (
<div
className={`bg-white rounded-xl shadow-sm border border-gray-100 p-4 ${onClick ? "cursor-pointer hover:shadow-md transition-shadow" : ""} ${className}`}
onClick={onClick}
>
{children}
</div>
);
}
@@ -0,0 +1,19 @@
import type { ReactNode } from "react";
interface EmptyStateProps {
icon?: string;
title: string;
description?: string;
action?: ReactNode;
}
export function EmptyState({ icon = "📭", title, description, action }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<span className="text-5xl mb-4">{icon}</span>
<h3 className="text-lg font-medium text-gray-700 mb-1">{title}</h3>
{description && <p className="text-sm text-gray-500 mb-4">{description}</p>}
{action}
</div>
);
}
@@ -0,0 +1,22 @@
"use client";
import { type InputHTMLAttributes } from "react";
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
}
export function Input({ label, error, className = "", id, ...props }: InputProps) {
return (
<div className="w-full">
{label && <label htmlFor={id} className="block text-sm font-medium text-gray-700 mb-1">{label}</label>}
<input
id={id}
className={`w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent ${error ? "border-red-300" : "border-gray-300"} ${className}`}
{...props}
/>
{error && <p className="mt-1 text-xs text-red-500">{error}</p>}
</div>
);
}
@@ -0,0 +1,33 @@
"use client";
import { useEffect, type ReactNode } from "react";
interface ModalProps {
open: boolean;
onClose: () => void;
title: string;
children: ReactNode;
}
export function Modal({ open, onClose, title, children }: ModalProps) {
useEffect(() => {
if (open) document.body.style.overflow = "hidden";
else document.body.style.overflow = "";
return () => { document.body.style.overflow = ""; };
}, [open]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
<div className="relative bg-white rounded-2xl shadow-xl max-w-lg w-full mx-4 p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">{title}</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none">&times;</button>
</div>
{children}
</div>
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
"use client";
import { useState, useEffect, useCallback } from "react";
// Generic data type for auth
type Auth = Record<string, unknown>;
/**
* Fetch and manage auth data.
*/
export function useAuth() {
const [data, setData] = useState<Auth[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
try {
setLoading(true);
setError(null);
const res = await fetch(`/api/auth`);
const json = await res.json();
setData(Array.isArray(json) ? json : json?.data || []);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load data");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
return { data, loading, error, refetch: fetchData };
}
@@ -0,0 +1,35 @@
"use client";
import { useState, useEffect, useCallback } from "react";
// Generic data type for customers
type Customer = Record<string, unknown>;
/**
* Fetch and manage customers data.
*/
export function useCustomers() {
const [data, setData] = useState<Customer[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
try {
setLoading(true);
setError(null);
const res = await fetch(`/api/customers`);
const json = await res.json();
setData(Array.isArray(json) ? json : json?.data || []);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load data");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
return { data, loading, error, refetch: fetchData };
}
@@ -0,0 +1,35 @@
"use client";
import { useState, useEffect, useCallback } from "react";
// Generic data type for dashboard
type Dashboard = Record<string, unknown>;
/**
* Fetch and manage dashboard data.
*/
export function useDashboard() {
const [data, setData] = useState<Dashboard[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
try {
setLoading(true);
setError(null);
const res = await fetch(`/api/dashboard`);
const json = await res.json();
setData(Array.isArray(json) ? json : json?.data || []);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load data");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
return { data, loading, error, refetch: fetchData };
}
@@ -0,0 +1,14 @@
"use client";
import { useState, useEffect } from "react";
export function useDebounce<T>(value: T, delay: number = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
@@ -0,0 +1,35 @@
"use client";
import { useState, useEffect, useCallback } from "react";
// Generic data type for follow_ups
type Follow_up = Record<string, unknown>;
/**
* Fetch and manage follow_ups data.
*/
export function useFollow_ups() {
const [data, setData] = useState<Follow_up[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
try {
setLoading(true);
setError(null);
const res = await fetch(`/api/follow_ups`);
const json = await res.json();
setData(Array.isArray(json) ? json : json?.data || []);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load data");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
return { data, loading, error, refetch: fetchData };
}
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { useState, useCallback } from "react";
interface UseFormOptions<T> {
initialValues: T;
onSubmit: (values: T) => Promise<void>;
}
export function useForm<T extends Record<string, unknown>>({ initialValues, onSubmit }: UseFormOptions<T>) {
const [values, setValues] = useState<T>(initialValues);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const setField = useCallback(<K extends keyof T>(field: K, value: T[K]) => {
setValues(prev => ({ ...prev, [field]: value }));
}, []);
const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
try {
setSubmitting(true);
setError(null);
await onSubmit(values);
} catch (err) {
setError(err instanceof Error ? err.message : "Submit failed");
} finally {
setSubmitting(false);
}
}, [values, onSubmit]);
return { values, setField, submitting, error, handleSubmit };
}
+35
View File
@@ -0,0 +1,35 @@
"use client";
import { useState, useEffect, useCallback } from "react";
// Generic data type for 销售漏斗
type Item = Record<string, unknown>;
/**
* Fetch and manage 销售漏斗 data.
*/
export function useItem() {
const [data, setData] = useState<Item[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
try {
setLoading(true);
setError(null);
const res = await fetch(`/api/销售漏斗`);
const json = await res.json();
setData(Array.isArray(json) ? json : json?.data || []);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load data");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
return { data, loading, error, refetch: fetchData };
}
+35
View File
@@ -0,0 +1,35 @@
"use client";
import { useState, useEffect, useCallback } from "react";
// Generic data type for notes
type Note = Record<string, unknown>;
/**
* Fetch and manage notes data.
*/
export function useNotes() {
const [data, setData] = useState<Note[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
try {
setLoading(true);
setError(null);
const res = await fetch(`/api/notes`);
const json = await res.json();
setData(Array.isArray(json) ? json : json?.data || []);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load data");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
return { data, loading, error, refetch: fetchData };
}
@@ -0,0 +1,35 @@
"use client";
import { useState, useEffect, useCallback } from "react";
// Generic data type for sales_pipeline
type Sales_pipeline = Record<string, unknown>;
/**
* Fetch and manage sales_pipeline data.
*/
export function useSales_pipeline() {
const [data, setData] = useState<Sales_pipeline[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
try {
setLoading(true);
setError(null);
const res = await fetch(`/api/sales_pipeline`);
const json = await res.json();
setData(Array.isArray(json) ? json : json?.data || []);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load data");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
return { data, loading, error, refetch: fetchData };
}
+35
View File
@@ -0,0 +1,35 @@
"use client";
import { useState, useEffect, useCallback } from "react";
// Generic data type for tags
type Tag = Record<string, unknown>;
/**
* Fetch and manage tags data.
*/
export function useTags() {
const [data, setData] = useState<Tag[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
try {
setLoading(true);
setError(null);
const res = await fetch(`/api/tags`);
const json = await res.json();
setData(Array.isArray(json) ? json : json?.data || []);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load data");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
return { data, loading, error, refetch: fetchData };
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+25
View File
@@ -0,0 +1,25 @@
{
"name": "noteapp",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit"
},
"dependencies": {
"next": "^15.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"typescript": "^5.6.0",
"tailwindcss": "^3.4.0",
"postcss": "^8.4.0",
"autoprefixer": "^10.4.0"
}
}
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+47
View File
@@ -0,0 +1,47 @@
// Auto-generated API client for NoteApp
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001/api";
interface RequestOptions extends RequestInit {
params?: Record<string, string | number>;
}
async function request<T>(endpoint: string, options: RequestOptions = {}): Promise<T> {
const { params, ...init } = options;
let url = `${API_BASE}${endpoint}`;
if (params) {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => searchParams.set(k, String(v)));
url += `?${searchParams.toString()}`;
}
const res = await fetch(url, {
...init,
headers: {
"Content-Type": "application/json",
...init.headers,
},
});
if (!res.ok) {
const err = await res.json().catch(() => ({ message: res.statusText }));
throw new Error(err.message || `API Error: ${res.status}`);
}
return res.json();
}
export const api = {
get: <T>(url: string, params?: Record<string, string | number>) =>
request<T>(url, { method: "GET", params }),
post: <T>(url: string, data?: unknown) =>
request<T>(url, { method: "POST", body: JSON.stringify(data) }),
put: <T>(url: string, data?: unknown) =>
request<T>(url, { method: "PUT", body: JSON.stringify(data) }),
delete: <T>(url: string) =>
request<T>(url, { method: "DELETE" }),
};
+10
View File
@@ -0,0 +1,10 @@
// Auto-generated auth service
import { api } from "./api";
export function postlogin(data: Record<string, unknown>) {
return api.post("/api/auth", data);
}
export function getme() {
return api.get("/api/auth");
}
@@ -0,0 +1,10 @@
// Auto-generated customers service
import { api } from "./api";
export function get() {
return api.get("/api/customers");
}
export function post(data: Record<string, unknown>) {
return api.post("/api/customers", data);
}
@@ -0,0 +1,10 @@
// Auto-generated dashboard service
import { api } from "./api";
export function get() {
return api.get("/api/dashboard");
}
export function post(data: Record<string, unknown>) {
return api.post("/api/dashboard", data);
}
@@ -0,0 +1,10 @@
// Auto-generated follow_ups service
import { api } from "./api";
export function get() {
return api.get("/api/follow_ups");
}
export function post(data: Record<string, unknown>) {
return api.post("/api/follow_ups", data);
}
+18
View File
@@ -0,0 +1,18 @@
// Auto-generated notes service
import { api } from "./api";
export function get() {
return api.get("/api/notes");
}
export function post(data: Record<string, unknown>) {
return api.post("/api/notes", data);
}
export function put_id(id: string, data: Record<string, unknown>) {
return api.put(`/api/notes/${id}`, data);
}
export function delete_id(id: string) {
return api.delete(`/api/notes/${id}`);
}
@@ -0,0 +1,10 @@
// Auto-generated sales_pipeline service
import { api } from "./api";
export function get() {
return api.get("/api/sales_pipeline");
}
export function post(data: Record<string, unknown>) {
return api.post("/api/sales_pipeline", data);
}
+6
View File
@@ -0,0 +1,6 @@
// Auto-generated tags service
import { api } from "./api";
export function get() {
return api.get("/api/tags");
}
@@ -0,0 +1,10 @@
// Auto-generated 客户管理 service
import { api } from "./api";
export function get() {
return api.get("/api/客户管理");
}
export function post(data: Record<string, unknown>) {
return api.post("/api/客户管理", data);
}
@@ -0,0 +1,10 @@
// Auto-generated 数据分析仪表盘 service
import { api } from "./api";
export function get() {
return api.get("/api/数据分析仪表盘");
}
export function post(data: Record<string, unknown>) {
return api.post("/api/数据分析仪表盘", data);
}
@@ -0,0 +1,10 @@
// Auto-generated 跟进记录 service
import { api } from "./api";
export function get() {
return api.get("/api/跟进记录");
}
export function post(data: Record<string, unknown>) {
return api.post("/api/跟进记录", data);
}
@@ -0,0 +1,10 @@
// Auto-generated 销售漏斗 service
import { api } from "./api";
export function get() {
return api.get("/api/销售漏斗");
}
export function post(data: Record<string, unknown>) {
return api.post("/api/销售漏斗", data);
}
@@ -0,0 +1,10 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: ["./app/**/*.{js,ts,jsx,tsx,mdx}", "./components/**/*.{js,ts,jsx,tsx,mdx}", "./hooks/**/*.{js,ts,jsx,tsx,mdx}", "./services/**/*.{js,ts,jsx,tsx,mdx}"],
theme: {
extend: {},
},
plugins: [],
};
export default config;

Some files were not shown because too many files have changed in this diff Show More