diff --git a/docs/api/README.md b/docs/api/README.md index dba2617..b5494c4 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -265,9 +265,11 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route | [`routes/pos/pimage.md`](routes/pos/pimage.md) | `GET /v1/pimage` | | [`routes/v1/product.md`](routes/v1/product.md) | `GET /v1/product` | | [`routes/vfs/list.md`](routes/vfs/list.md) | `GET /vfs` | +| [`routes/vfs/applets-restore.md`](routes/vfs/applets-restore.md) | `POST /vfs/applets/restore` | | [`routes/vfs/drafts/response.md`](routes/vfs/drafts/response.md) | `POST /vfs/drafts/response` | | [`routes/vfs/mkdir.md`](routes/vfs/mkdir.md) | `POST /vfs/mkdir` | | [`routes/vfs/rename.md`](routes/vfs/rename.md) | `POST /vfs/rename` | +| [`routes/vfs/skills-restore.md`](routes/vfs/skills-restore.md) | `POST /vfs/skills/restore` | | [`routes/vfs/upload.md`](routes/vfs/upload.md) | `POST /vfs/upload` | | [`routes/vfs/delete.md`](routes/vfs/delete.md) | `DELETE /vfs/{*path}` | | [`routes/vfs/_*path.md`](routes/vfs/_*path.md) | `GET /vfs/{*path}` | @@ -305,7 +307,7 @@ Clients subscribe via [`GET /events`](routes/events.md) (`text/event-stream`). A | `quote` | `DELETE /quote/{id}/file/{fileId}`, `POST /quote/{id}/pdf` | | `shipment` | `PATCH /shipment/{id}`, `DELETE /shipment/{id}/file/{fileId}`, `POST /shipment/{id}/pdf` | | `stock` | `POST /stock/adjust` | -| `vfs` | `POST /vfs/mkdir`, `POST /vfs/rename`, `POST /vfs/upload`, `DELETE /vfs/{*path}`, `POST /vfs/{*path}`, `PUT /vfs/{*path}` | +| `vfs` | `POST /vfs/applets/restore`, `POST /vfs/mkdir`, `POST /vfs/rename`, `POST /vfs/skills/restore`, `POST /vfs/upload`, `DELETE /vfs/{*path}`, `POST /vfs/{*path}`, `PUT /vfs/{*path}` | | `warehouse` | `POST /warehouse`, `PUT /warehouse/order`, `DELETE /warehouse/{id}`, `PUT /warehouse/{id}` | ## DESIGN diff --git a/docs/api/routes-all.md b/docs/api/routes-all.md index 7269b2e..b91f801 100644 --- a/docs/api/routes-all.md +++ b/docs/api/routes-all.md @@ -1,6 +1,6 @@ # Dash API Routes Overview (All) -Overview of all 248 API endpoints across all modules. +Overview of all 250 API endpoints across all modules. ### Notation @@ -912,6 +912,9 @@ _none_ #### List virtual filesystem files — `GET /vfs` _none_ - Returns: `200 { files: { name: str, size: int, mime: str, updated_at: str, created_at: str, isText: bool, isImage: bool, isPdf: bool }[] }` +#### Restore system default applets — `POST /vfs/applets/restore` +_none_ +- Returns: `200 { success: bool, restored: str[], count: int }` #### Respond to a frontend draft request — `POST /vfs/drafts/response` - Params: `body: { requestId: str, content?: any, error?: str, files?: any[] }` - Returns: `200 { success: bool }` @@ -921,6 +924,9 @@ _none_ #### Rename or move a virtual filesystem file or directory — `POST /vfs/rename` - Params: `body: { oldPath: str, newPath: str }` - Returns: `200 { success: bool, oldPath: str, newPath: str }` +#### Restore system default skills — `POST /vfs/skills/restore` +_none_ +- Returns: `200 { success: bool, restored: str[], count: int }` #### Upload files to virtual filesystem — `POST /vfs/upload` - Params: `form: { files: str[], targetDir?: str }` - Returns: `200 { success: bool, uploaded: { name: str, size: int, mime: str }[] }` diff --git a/docs/api/routes/vfs/applets-restore.md b/docs/api/routes/vfs/applets-restore.md new file mode 100644 index 0000000..9a8f0ec --- /dev/null +++ b/docs/api/routes/vfs/applets-restore.md @@ -0,0 +1,40 @@ +# `POST /vfs/applets/restore` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Restore system default applets + +Restores all shipped system default example applets (.applet.js) into the active virtual +filesystem (VFS) for the authenticated user's selected company, overwriting existing system applets with their upstream definitions. + + +## Signals + +On success, emits SSE signal: `vfs` (see [`GET /events`](../events.md)). + +## Response `200` + +System applets restored successfully + +```json +{ + "success": true, + "restored": [ + "…" + ], + "count": 1 +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | No company selected — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized — `{"error":"…"}` | +| 500 | Restore failed — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/vfs/skills-restore.md b/docs/api/routes/vfs/skills-restore.md new file mode 100644 index 0000000..5b128c2 --- /dev/null +++ b/docs/api/routes/vfs/skills-restore.md @@ -0,0 +1,40 @@ +# `POST /vfs/skills/restore` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Restore system default skills + +Restores all shipped system default SKILL files into the active virtual filesystem (VFS) +for the authenticated user's selected company, overwriting existing system skills with their upstream definitions. + + +## Signals + +On success, emits SSE signal: `vfs` (see [`GET /events`](../events.md)). + +## Response `200` + +System skills restored successfully + +```json +{ + "success": true, + "restored": [ + "…" + ], + "count": 1 +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | No company selected — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized — `{"error":"…"}` | +| 500 | Restore failed — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/src/api/routes/vfs.js b/src/api/routes/vfs.js index 7788f70..bbb2b27 100644 --- a/src/api/routes/vfs.js +++ b/src/api/routes/vfs.js @@ -2,7 +2,14 @@ import fs from 'fs'; import path from 'path'; import multer from 'multer'; import { emit } from '../sse.js'; -import { prepareVfs, resolveVfsRoot, isReadOnlyDatabasePath, getMimeType } from '../../tools/vfs.js'; +import { + prepareVfs, + resolveVfsRoot, + isReadOnlyDatabasePath, + getMimeType, + restoreDefaultSkills, + restoreDefaultApplets, +} from '../../tools/vfs.js'; import sharp from 'sharp'; import { parseDraftPath, @@ -1464,4 +1471,162 @@ export function register(app, basePath = '') { }); // #endregion + + /** + * @openapi + * /vfs/skills/restore: + * post: + * tags: [Vfs] + * summary: Restore system default skills + * description: | + * Restores all shipped system default SKILL files into the active virtual filesystem (VFS) + * for the authenticated user's selected company, overwriting existing system skills with their upstream definitions. + * security: + * - cookieAuth: [] + * x-signals: [vfs] + * x-markdown: vfs/skills-restore.md + * responses: + * 200: + * description: System skills restored successfully + * content: + * application/json: + * schema: + * type: object + * required: [success, restored, count] + * properties: + * success: + * type: boolean + * restored: + * type: array + * items: + * type: string + * count: + * type: integer + * 400: + * description: No company selected + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 401: + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 403: + * description: Not authorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 500: + * description: Restore failed + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ + // #region POST /vfs/skills/restore + + app.post(`${basePath}/vfs/skills/restore`, async (req, res) => { + const selected = requireSelectedCompany(req); + if (selected.error) { + return res.status(400).json({ error: selected.error }); + } + try { + const restored = await restoreDefaultSkills({ googleAuthId: req.user.id }); + emit('vfs', { companyId: selected.companyId }); + return res.json({ + success: true, + restored, + count: restored.length, + }); + } catch (error) { + const status = vfsFileErrorStatus(error); + if (status === 500) console.error('Restore default skills failed:', error); + return res.status(status).json({ error: error.message }); + } + }); + + // #endregion + + /** + * @openapi + * /vfs/applets/restore: + * post: + * tags: [Vfs] + * summary: Restore system default applets + * description: | + * Restores all shipped system default example applets (.applet.js) into the active virtual + * filesystem (VFS) for the authenticated user's selected company, overwriting existing system applets with their upstream definitions. + * security: + * - cookieAuth: [] + * x-signals: [vfs] + * x-markdown: vfs/applets-restore.md + * responses: + * 200: + * description: System applets restored successfully + * content: + * application/json: + * schema: + * type: object + * required: [success, restored, count] + * properties: + * success: + * type: boolean + * restored: + * type: array + * items: + * type: string + * count: + * type: integer + * 400: + * description: No company selected + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 401: + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 403: + * description: Not authorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 500: + * description: Restore failed + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ + // #region POST /vfs/applets/restore + + app.post(`${basePath}/vfs/applets/restore`, async (req, res) => { + const selected = requireSelectedCompany(req); + if (selected.error) { + return res.status(400).json({ error: selected.error }); + } + try { + const restored = await restoreDefaultApplets({ googleAuthId: req.user.id }); + emit('vfs', { companyId: selected.companyId }); + return res.json({ + success: true, + restored, + count: restored.length, + }); + } catch (error) { + const status = vfsFileErrorStatus(error); + if (status === 500) console.error('Restore default applets failed:', error); + return res.status(status).json({ error: error.message }); + } + }); + + // #endregion } \ No newline at end of file diff --git a/src/api/routes/vfsExec.js b/src/api/routes/vfsExec.js index c38443d..62c8b4d 100644 --- a/src/api/routes/vfsExec.js +++ b/src/api/routes/vfsExec.js @@ -10,7 +10,7 @@ import { parseVfsRef, vfsFileErrorStatus } from './vfs.js'; // VFS paths that are owned by their own literal route in `vfs.js`. They are registered as // separate Express handlers, so this wildcard must defer to them instead of treating e.g. // `/vfs/upload` as an applet path. Deferring keeps the two files order-independent. -const RESERVED_VFS_SEGMENTS = new Set(['upload', 'mkdir', 'rename']); +const RESERVED_VFS_SEGMENTS = new Set(['upload', 'mkdir', 'rename', 'drafts', 'skills', 'applets']); function requireSelectedCompany(req) { const companyId = req.user?.selectedCompanyId; diff --git a/src/tools/vfs.js b/src/tools/vfs.js index e861ceb..f979516 100644 --- a/src/tools/vfs.js +++ b/src/tools/vfs.js @@ -162,8 +162,8 @@ export async function loadDefaultSkills({ googleAuthId, store: passedStore, vfsR if (!fs.existsSync(diskPath)) { fs.mkdirSync(path.dirname(diskPath), { recursive: true }); fs.writeFileSync(diskPath, skill.content, 'utf8'); + loaded.push(skill.name); } - loaded.push(skill.name); } catch (err) { console.error(`[vfs] failed to write default skill "${skill.name}" to disk:`, err.message); } @@ -172,6 +172,34 @@ export async function loadDefaultSkills({ googleAuthId, store: passedStore, vfsR return loaded; } +/** + * Restores all shipped system default SKILL files into the company's VFS. + * + * @param {{ googleAuthId: number, store?: object, vfsRoot?: string }} ctx + * @returns {Promise>} List of restored skill file names + */ +export async function restoreDefaultSkills({ googleAuthId, store: passedStore, vfsRoot: passedVfsRoot } = {}) { + const store = passedStore || await getStore(); + const vfsRoot = passedVfsRoot || await vfsRootFor(store, googleAuthId); + fs.mkdirSync(vfsRoot, { recursive: true }); + + const defaultSkills = getDefaultSkills(); + const restored = []; + + for (const skill of defaultSkills) { + try { + const diskPath = path.resolve(vfsRoot, skill.name); + fs.mkdirSync(path.dirname(diskPath), { recursive: true }); + fs.writeFileSync(diskPath, skill.content, 'utf8'); + restored.push(skill.name); + } catch (err) { + console.error(`[vfs] failed to restore default skill "${skill.name}":`, err.message); + } + } + + return restored; +} + /** * Loads the shipped example `.applet.js` files into the selected company's VFS. * @@ -194,8 +222,8 @@ export async function loadDefaultApplets({ googleAuthId, store: passedStore, vfs const diskPath = path.resolve(vfsRoot, name); if (!fs.existsSync(diskPath)) { fs.writeFileSync(diskPath, applet.content, 'utf8'); + seeded.push(name); } - seeded.push(name); } catch (err) { console.error(`[vfs] failed to write default applet "${name}" to disk:`, err.message); } @@ -204,6 +232,32 @@ export async function loadDefaultApplets({ googleAuthId, store: passedStore, vfs return seeded; } +/** + * Restores all shipped system default example applets into the company's VFS. + * + * @param {{ googleAuthId: number, store?: object, vfsRoot?: string }} ctx + * @returns {Promise>} List of restored applet file names + */ +export async function restoreDefaultApplets({ googleAuthId, store: passedStore, vfsRoot: passedVfsRoot } = {}) { + const store = passedStore || await getStore(); + const vfsRoot = passedVfsRoot || await vfsRootFor(store, googleAuthId); + fs.mkdirSync(vfsRoot, { recursive: true }); + + const restored = []; + for (const applet of getDefaultApplets()) { + const name = `${DEFAULT_APPLETS_VERSION}-${applet.name}`; + try { + const diskPath = path.resolve(vfsRoot, name); + fs.writeFileSync(diskPath, applet.content, 'utf8'); + restored.push(name); + } catch (err) { + console.error(`[vfs] failed to restore default applet "${name}":`, err.message); + } + } + + return restored; +} + /** * Initializes the VFS for the selected company with default SKILL files and example applets. *