Tataaicoltd commited on
Commit
fb78baf
·
verified ·
1 Parent(s): 8d0f3eb

deploy: add /chefsys routes (foods, drinks, add-on, order)

Browse files
Files changed (3) hide show
  1. server.js +45 -0
  2. src/openapi.js +146 -1
  3. src/routes/chefsys.js +125 -0
server.js CHANGED
@@ -8,6 +8,7 @@ import swaggerUi from 'swagger-ui-express';
8
  import projectsRouter from './src/routes/projects.js';
9
  import ticketsRouter from './src/routes/tickets.js';
10
  import flowRouter from './src/routes/flow.js';
 
11
  import { openapiSpec } from './src/openapi.js';
12
 
13
  dotenv.config();
@@ -18,6 +19,49 @@ const __dirname = path.dirname(__filename);
18
  const app = express();
19
  const PORT = process.env.PORT || 3000;
20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  app.use(cors());
22
  app.use(express.json({ limit: '5mb' }));
23
 
@@ -25,6 +69,7 @@ app.use(express.json({ limit: '5mb' }));
25
  app.use('/api/projects', projectsRouter);
26
  app.use('/api/tickets', ticketsRouter);
27
  app.use('/flow/tickets', flowRouter);
 
28
 
29
  app.get('/api/health', (req, res) => res.json({ ok: true }));
30
 
 
8
  import projectsRouter from './src/routes/projects.js';
9
  import ticketsRouter from './src/routes/tickets.js';
10
  import flowRouter from './src/routes/flow.js';
11
+ import chefsysRouter from './src/routes/chefsys.js';
12
  import { openapiSpec } from './src/openapi.js';
13
 
14
  dotenv.config();
 
19
  const app = express();
20
  const PORT = process.env.PORT || 3000;
21
 
22
+ // Derive the Supabase storage origin so images can be whitelisted explicitly
23
+ // (instead of using a wildcard) in the Content-Security-Policy.
24
+ let supabaseOrigin = '';
25
+ try {
26
+ if (process.env.SUPABASE_URL) supabaseOrigin = new URL(process.env.SUPABASE_URL).origin;
27
+ } catch {
28
+ supabaseOrigin = '';
29
+ }
30
+
31
+ const baseCsp = [
32
+ "default-src 'self'",
33
+ "script-src 'self'",
34
+ "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
35
+ "font-src 'self' https://fonts.gstatic.com",
36
+ `img-src 'self' data: blob:${supabaseOrigin ? ' ' + supabaseOrigin : ''}`,
37
+ `connect-src 'self'${supabaseOrigin ? ' ' + supabaseOrigin : ''}`,
38
+ "object-src 'none'",
39
+ "base-uri 'self'",
40
+ "form-action 'self'",
41
+ "frame-ancestors 'none'"
42
+ ].join('; ');
43
+
44
+ // Swagger UI ships inline scripts/styles, so it needs a slightly relaxed policy.
45
+ const swaggerCsp = [
46
+ "default-src 'self'",
47
+ "script-src 'self' 'unsafe-inline'",
48
+ "style-src 'self' 'unsafe-inline'",
49
+ "img-src 'self' data:",
50
+ "object-src 'none'",
51
+ "base-uri 'self'",
52
+ "frame-ancestors 'none'"
53
+ ].join('; ');
54
+
55
+ app.use((req, res, next) => {
56
+ res.setHeader(
57
+ 'Content-Security-Policy',
58
+ req.path.startsWith('/api-docs') ? swaggerCsp : baseCsp
59
+ );
60
+ res.setHeader('X-Content-Type-Options', 'nosniff');
61
+ res.setHeader('Referrer-Policy', 'no-referrer');
62
+ next();
63
+ });
64
+
65
  app.use(cors());
66
  app.use(express.json({ limit: '5mb' }));
67
 
 
69
  app.use('/api/projects', projectsRouter);
70
  app.use('/api/tickets', ticketsRouter);
71
  app.use('/flow/tickets', flowRouter);
72
+ app.use('/chefsys', chefsysRouter);
73
 
74
  app.get('/api/health', (req, res) => res.json({ ok: true }));
75
 
src/openapi.js CHANGED
@@ -13,7 +13,8 @@ export const openapiSpec = {
13
  { name: 'Health', description: 'Service health' },
14
  { name: 'Projects', description: 'Project management' },
15
  { name: 'Tickets', description: 'Ticket management (board)' },
16
- { name: 'Flow', description: 'Lightweight ticket flow API for agents' }
 
17
  ],
18
  components: {
19
  schemas: {
@@ -93,6 +94,65 @@ export const openapiSpec = {
93
  message: { type: 'string' },
94
  detail: { type: 'string' }
95
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  }
97
  },
98
  responses: {
@@ -429,6 +489,91 @@ export const openapiSpec = {
429
  404: { $ref: '#/components/responses/NotFound' }
430
  }
431
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
432
  }
433
  }
434
  };
 
13
  { name: 'Health', description: 'Service health' },
14
  { name: 'Projects', description: 'Project management' },
15
  { name: 'Tickets', description: 'Ticket management (board)' },
16
+ { name: 'Flow', description: 'Lightweight ticket flow API for agents' },
17
+ { name: 'ChefSys', description: 'Chef system: menu items (foods/drinks/add-ons) & orders' }
18
  ],
19
  components: {
20
  schemas: {
 
94
  message: { type: 'string' },
95
  detail: { type: 'string' }
96
  }
97
+ },
98
+ ChefMenuItem: {
99
+ type: 'object',
100
+ properties: {
101
+ id: { type: 'integer', readOnly: true },
102
+ name: { type: 'string' },
103
+ price: { type: 'number' },
104
+ description: { type: 'string', nullable: true },
105
+ creatid: { type: 'string', format: 'date-time', readOnly: true }
106
+ }
107
+ },
108
+ ChefAddon: {
109
+ type: 'object',
110
+ properties: {
111
+ id: { type: 'integer', readOnly: true },
112
+ name: { type: 'string' },
113
+ price: { type: 'number' },
114
+ creatid: { type: 'string', format: 'date-time', readOnly: true }
115
+ }
116
+ },
117
+ ChefOrderItem: {
118
+ type: 'object',
119
+ properties: {
120
+ type: { type: 'string', enum: ['food', 'drink', 'addon'] },
121
+ id: { type: 'integer' },
122
+ name: { type: 'string', readOnly: true },
123
+ price: { type: 'number', readOnly: true },
124
+ qty: { type: 'integer' }
125
+ }
126
+ },
127
+ ChefOrder: {
128
+ type: 'object',
129
+ properties: {
130
+ id: { type: 'integer', readOnly: true },
131
+ client: { type: 'integer' },
132
+ total_item: { type: 'integer', readOnly: true },
133
+ total_price: { type: 'integer', readOnly: true },
134
+ detail: { type: 'array', items: { $ref: '#/components/schemas/ChefOrderItem' } },
135
+ created_at: { type: 'string', format: 'date-time', readOnly: true }
136
+ }
137
+ },
138
+ ChefOrderInput: {
139
+ type: 'object',
140
+ required: ['client', 'items'],
141
+ properties: {
142
+ client: { type: 'integer' },
143
+ items: {
144
+ type: 'array',
145
+ items: {
146
+ type: 'object',
147
+ required: ['type', 'id', 'qty'],
148
+ properties: {
149
+ type: { type: 'string', enum: ['food', 'drink', 'addon'] },
150
+ id: { type: 'integer' },
151
+ qty: { type: 'integer', minimum: 1 }
152
+ }
153
+ }
154
+ }
155
+ }
156
  }
157
  },
158
  responses: {
 
489
  404: { $ref: '#/components/responses/NotFound' }
490
  }
491
  }
492
+ },
493
+ '/chefsys/foods': {
494
+ get: {
495
+ tags: ['ChefSys'],
496
+ summary: 'List foods',
497
+ responses: {
498
+ 200: {
499
+ description: 'Array of foods',
500
+ content: {
501
+ 'application/json': {
502
+ schema: { type: 'array', items: { $ref: '#/components/schemas/ChefMenuItem' } }
503
+ }
504
+ }
505
+ },
506
+ 500: { $ref: '#/components/responses/ServerError' }
507
+ }
508
+ }
509
+ },
510
+ '/chefsys/drinks': {
511
+ get: {
512
+ tags: ['ChefSys'],
513
+ summary: 'List drinks',
514
+ responses: {
515
+ 200: {
516
+ description: 'Array of drinks',
517
+ content: {
518
+ 'application/json': {
519
+ schema: { type: 'array', items: { $ref: '#/components/schemas/ChefMenuItem' } }
520
+ }
521
+ }
522
+ },
523
+ 500: { $ref: '#/components/responses/ServerError' }
524
+ }
525
+ }
526
+ },
527
+ '/chefsys/add-on': {
528
+ get: {
529
+ tags: ['ChefSys'],
530
+ summary: 'List add-ons',
531
+ responses: {
532
+ 200: {
533
+ description: 'Array of add-ons',
534
+ content: {
535
+ 'application/json': {
536
+ schema: { type: 'array', items: { $ref: '#/components/schemas/ChefAddon' } }
537
+ }
538
+ }
539
+ },
540
+ 500: { $ref: '#/components/responses/ServerError' }
541
+ }
542
+ }
543
+ },
544
+ '/chefsys/order': {
545
+ get: {
546
+ tags: ['ChefSys'],
547
+ summary: 'List all orders',
548
+ responses: {
549
+ 200: {
550
+ description: 'Array of orders',
551
+ content: {
552
+ 'application/json': {
553
+ schema: { type: 'array', items: { $ref: '#/components/schemas/ChefOrder' } }
554
+ }
555
+ }
556
+ },
557
+ 500: { $ref: '#/components/responses/ServerError' }
558
+ }
559
+ },
560
+ post: {
561
+ tags: ['ChefSys'],
562
+ summary: 'Create an order',
563
+ description: 'Item name/price are re-resolved server-side from foods/drinks/add-ons; total is computed from them.',
564
+ requestBody: {
565
+ required: true,
566
+ content: { 'application/json': { schema: { $ref: '#/components/schemas/ChefOrderInput' } } }
567
+ },
568
+ responses: {
569
+ 201: {
570
+ description: 'Created',
571
+ content: { 'application/json': { schema: { $ref: '#/components/schemas/ChefOrder' } } }
572
+ },
573
+ 400: { $ref: '#/components/responses/BadRequest' },
574
+ 500: { $ref: '#/components/responses/ServerError' }
575
+ }
576
+ }
577
  }
578
  }
579
  };
src/routes/chefsys.js ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Router } from 'express';
2
+ import { supabase } from '../supabase.js';
3
+
4
+ // ChefSys API: menu (foods/drinks/add-ons) + orders. Mounted at /chefsys.
5
+ const router = Router();
6
+
7
+ const FOOD_TABLE = 'chef_food';
8
+ const DRINK_TABLE = 'chef_drink';
9
+ const ADDON_TABLE = 'chef_addon';
10
+ const ORDER_TABLE = 'chef_order';
11
+
12
+ const ITEM_TABLES = {
13
+ food: FOOD_TABLE,
14
+ drink: DRINK_TABLE,
15
+ addon: ADDON_TABLE
16
+ };
17
+
18
+ const SMALLINT_MAX = 32767; // chef_order.total_item / total_price are smallint columns
19
+
20
+ // GET /chefsys/foods
21
+ router.get('/foods', async (req, res) => {
22
+ const { data, error } = await supabase
23
+ .from(FOOD_TABLE)
24
+ .select('*')
25
+ .order('id', { ascending: true });
26
+ if (error) return res.status(500).json({ error: error.message });
27
+ res.json(data);
28
+ });
29
+
30
+ // GET /chefsys/drinks
31
+ router.get('/drinks', async (req, res) => {
32
+ const { data, error } = await supabase
33
+ .from(DRINK_TABLE)
34
+ .select('*')
35
+ .order('id', { ascending: true });
36
+ if (error) return res.status(500).json({ error: error.message });
37
+ res.json(data);
38
+ });
39
+
40
+ // GET /chefsys/add-on
41
+ router.get('/add-on', async (req, res) => {
42
+ const { data, error } = await supabase
43
+ .from(ADDON_TABLE)
44
+ .select('*')
45
+ .order('id', { ascending: true });
46
+ if (error) return res.status(500).json({ error: error.message });
47
+ res.json(data);
48
+ });
49
+
50
+ // GET /chefsys/order — all orders
51
+ router.get('/order', async (req, res) => {
52
+ const { data, error } = await supabase
53
+ .from(ORDER_TABLE)
54
+ .select('*')
55
+ .order('id', { ascending: true });
56
+ if (error) return res.status(500).json({ error: error.message });
57
+ res.json(data);
58
+ });
59
+
60
+ // POST /chefsys/order body: { client, items: [{ type: 'food'|'drink'|'addon', id, qty }] }
61
+ router.post('/order', async (req, res) => {
62
+ const { client, items } = req.body;
63
+ if (client == null || !Number.isInteger(Number(client))) {
64
+ return res.status(400).json({ error: 'client is required and must be an integer' });
65
+ }
66
+ if (!Array.isArray(items) || items.length === 0) {
67
+ return res.status(400).json({ error: 'items is required and must be a non-empty array' });
68
+ }
69
+ for (const it of items) {
70
+ if (!it || !ITEM_TABLES[it.type]) {
71
+ return res.status(400).json({ error: `item type must be one of ${Object.keys(ITEM_TABLES).join(', ')}` });
72
+ }
73
+ if (it.id == null) return res.status(400).json({ error: 'item id is required' });
74
+ if (!Number.isInteger(Number(it.qty)) || Number(it.qty) < 1) {
75
+ return res.status(400).json({ error: 'item qty must be a positive integer' });
76
+ }
77
+ }
78
+
79
+ let resolved;
80
+ try {
81
+ resolved = await resolveItems(items);
82
+ } catch (err) {
83
+ return res.status(400).json({ error: err.message });
84
+ }
85
+
86
+ const total_item = resolved.reduce((sum, it) => sum + it.qty, 0);
87
+ const total_price = resolved.reduce((sum, it) => sum + it.price * it.qty, 0);
88
+ if (total_item > SMALLINT_MAX || total_price > SMALLINT_MAX) {
89
+ return res.status(400).json({ error: `order total exceeds the maximum allowed value (${SMALLINT_MAX})` });
90
+ }
91
+
92
+ const { data, error } = await supabase
93
+ .from(ORDER_TABLE)
94
+ .insert({ client: Number(client), total_item, total_price, detail: resolved })
95
+ .select('*')
96
+ .single();
97
+ if (error) return res.status(500).json({ error: error.message });
98
+ res.status(201).json(data);
99
+ });
100
+
101
+ // Re-resolve name/price for each ordered item server-side (never trust client-supplied prices).
102
+ async function resolveItems(items) {
103
+ const idsByType = {};
104
+ for (const it of items) {
105
+ (idsByType[it.type] ??= new Set()).add(it.id);
106
+ }
107
+
108
+ const rowsByType = {};
109
+ for (const [type, ids] of Object.entries(idsByType)) {
110
+ const { data, error } = await supabase
111
+ .from(ITEM_TABLES[type])
112
+ .select('id, name, price')
113
+ .in('id', [...ids]);
114
+ if (error) throw new Error(error.message);
115
+ rowsByType[type] = new Map(data.map((row) => [row.id, row]));
116
+ }
117
+
118
+ return items.map((it) => {
119
+ const row = rowsByType[it.type].get(it.id);
120
+ if (!row) throw new Error(`${it.type} not found: ${it.id}`);
121
+ return { type: it.type, id: row.id, name: row.name, price: Number(row.price), qty: Number(it.qty) };
122
+ });
123
+ }
124
+
125
+ export default router;