Compare commits

...
Author SHA1 Message Date
Hanzo Dev 5981dca439 ci(docker): reduce to sync-notice — native pipeline is .hanzo/workflows/deploy.yml
Image build+push+deploy now runs in-cluster (act_runner + BuildKit → operator).
GitHub is a mirror; this workflow is retained only as a manual sync notice.
2026-07-24 15:14:30 -07:00
Hanzo Dev af73ce21fe ci(deploy): add native Hanzo deploy pipeline (BuildKit → ghcr → operator patch)
Native flow: Hanzo Git push → act_runner → BuildKit builds Dockerfile →
ghcr.io/hanzoai/dataroom:<sha> → kubectl patch app dataroom → operator reconcile.
GitHub Actions reduced to sync-only.
2026-07-24 15:14:20 -07:00
1b4bb4830b feat(goja): self-contained goja bundle — fold dataroom into hanzoai/cloud (#101) (#6)
* feat(goja): self-contained goja bundle — fold dataroom into hanzoai/cloud (#101)

Extract the dataroom business logic into a self-contained, ESM-free goja bundle
(goja/bundle.js) exposing globalThis.handle(req), authored to run verbatim inside
the unified hanzoai/cloud binary (HIP-0106, task #101 / epic #96) — the same
in-process pattern @hanzo/plans and @hanzo/pricing use.

The complete flow — documents (metadata; bytes on the cloud object-storage seam),
data rooms + membership, shareable links (email/allow-list gate + bcrypt password
+ expiry + download toggle), viewers, per-page view analytics — is re-expressed as
a route table over injected host functions (globalThis.db.query/exec bound
per-tenant to Base/SQLite, globalThis.crypto bcrypt helpers). The Prisma models
become CREATE TABLE statements in the bundle's migrate route. No Postgres, no
Next.js; the standalone pod is retired.

cloud/clients/dataroom go:embeds a byte-identical vendored copy (task mandates
go:embed here since this repo is a TS app, not a Go module).

Claude-Session: https://claude.ai/code/session_016yg7GPhYdWCh9vpp4HEwLZ

* goja: run on the shared clients/gojabase RW-Base binding (#101)

Rework the bundle onto the reusable clients/gojabase host contract (the RW-Base
goja binding captable pilots, esign reuses) instead of a bespoke DB binding — one
binding, not two. The bundle now calls __db.query/__db.exec, __newId, __now and
__bcrypt.hash/verify (the leaf's bcrypt HostFn), and handle({route,params,query,
orgId,body}); the per-tenant schema (DDL) moves to the Go leaf where gojabase runs
it on first open, and each dispatch is one per-tenant transaction (commits iff
status<400). No behaviour change to the dataroom flow; README updated.

Claude-Session: https://claude.ai/code/session_016yg7GPhYdWCh9vpp4HEwLZ

---------

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-10 03:06:43 -07:00
5 changed files with 498 additions and 20 deletions
+7 -20
View File
@@ -1,24 +1,11 @@
name: Docker
# Native deploy pipeline is .hanzo/workflows/deploy.yml (Hanzo Git → act_runner →
# BuildKit → ghcr.io/hanzoai/dataroom:<sha> → operator reconcile → hanzocd).
# GitHub is a mirror; this workflow is retained only as a manual sync notice.
on:
workflow_dispatch:
push:
branches: [main]
tags: ['v*']
permissions:
contents: read
packages: write
id-token: write
jobs:
docker:
uses: hanzoai/.github/.github/workflows/docker-build.yml@main
with:
image: ghcr.io/hanzoai/dataroom
# amd64-only: DOKS has no arm64 droplets (arm64 build paused).
platforms: linux/amd64
# Deploy is operator-managed via universe Service CR (services.hanzo.ai/dataroom).
# CI only builds+pushes the image; never `kubectl set image` (operator reverts it).
deploy: false
secrets: inherit
notice:
runs-on: ubuntu-latest
steps:
- run: echo "native pipeline is .hanzo/workflows/deploy.yml; GitHub is a mirror"
+24
View File
@@ -0,0 +1,24 @@
name: deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: hanzo-linux-amd64
steps:
- uses: actions/checkout@v4
- name: Build + push image
run: |
SHA="${GITHUB_SHA::8}"
buildctl-daemonless.sh build --frontend=dockerfile.v0 \
--opt context="${{ github.server_url }}/${{ github.repository }}.git#${GITHUB_SHA}" \
--opt filename=Dockerfile --opt platform=linux/amd64 \
--secret id=GIT_AUTH_TOKEN,env=GIT_AUTH_TOKEN \
--output "type=image,name=ghcr.io/hanzoai/dataroom:${SHA},push=true" --progress=plain
env:
GIT_AUTH_TOKEN: ${{ secrets.GIT_CLONE_TOKEN }}
- name: Deploy — declare tag to operator
run: |
for app in dataroom; do
kubectl -n hanzo patch app "$app" --type=merge -p "{\"spec\":{\"image\":{\"repository\":\"ghcr.io/hanzoai/dataroom\",\"tag\":\"${GITHUB_SHA::8}\"}}}"
done
+9
View File
@@ -5,6 +5,15 @@ Hanzo dataroom service.
**Upstream**: [Papermark](https://github.com/mfts/papermark) (AGPL-3.0). LICENSE retains "Copyright (c) 2023-present Papermark, Inc." Open-source DocSend alternative. This fork is Hanzo Dataroom — single-license AGPL, no `ee/` commercial directory.
## In-process fold (HIP-0106, task #101)
The production runtime is the **goja bundle** in [`goja/`](goja/): the ESM-free
port of the API handlers, run in-process inside the unified `hanzoai/cloud` binary
over Hanzo Base/SQLite (per-tenant) + the cloud object-storage seam. The standalone
Next.js app + Postgres pod is **retired**; cloud serves `/v1/dataroom/*` itself.
See [`goja/README.md`](goja/README.md) for the host contract. The Next.js/TS
sources below remain the reference for the domain model that `goja/bundle.js`
implements.
## Tech Stack
- **Language**: TypeScript/JavaScript
+75
View File
@@ -0,0 +1,75 @@
# dataroom — goja bundle (HIP-0106 fold, task #101)
This directory holds the **canonical, self-contained business-logic bundle** for
Hanzo Dataroom. It is the ESM-free port of the Next.js/Prisma API handlers,
authored to run verbatim inside the `dop251/goja` engine embedded in the unified
`hanzoai/cloud` binary — the same in-process pattern `@hanzo/plans` and
`@hanzo/pricing` use.
The standalone Next.js app + Postgres is **retired**: cloud serves the whole
dataroom surface (`/v1/dataroom/*`) itself, backed by Hanzo Base/SQLite
(one file per org) and the cloud object-storage seam. No Postgres, no Next.js.
It runs on the **reusable `clients/gojabase` binding** — the RW-Base goja host
`captable` pilots and `esign` reuses — so the bundle carries only domain logic;
persistence, per-tenant file selection, and per-request transactions are the
binding's job.
## What the bundle does
The complete dataroom flow, re-expressed as a route table over injected host
functions:
- **documents** — metadata rows; the file **bytes** live in object storage (the
cloud VFS/S3 data plane), handled by the Go leaf. The bundle only ever
stores/reads the opaque storage **key**.
- **data rooms** + **dataroom_document** — rooms and their document membership.
- **shareable links** — access controls: email gate + allow-list, bcrypt
password, expiry, download toggle, archive.
- **viewers** + **views** — a viewer authenticates against a link's controls; a
View (analytics session) is recorded.
- **per-page view analytics** — page-by-page tracking rows, aggregated per page
(`views`, `totalDuration`, `avgDuration`).
The Prisma models (`Document`, `Dataroom`, `DataroomDocument`, `Link`, `Viewer`,
`View`, per-page tracking) become the `CREATE TABLE` statements in the bundle's
`migrate` route — the one source of truth for the Base schema.
## Host contract
`clients/gojabase` injects these globals per dispatch (each dispatch runs inside
ONE per-tenant SQLite transaction that commits iff `status < 400`) and calls
`handle` once per request:
```
globalThis.__db.query(sql, args) -> [ {col: val, ...}, ... ] // tenant-scoped
globalThis.__db.exec(sql, args) -> { changes, lastId }
globalThis.__newId() -> collision-resistant id (crypto/rand)
globalThis.__now() -> unix milliseconds
globalThis.__bcrypt.hash(pw) -> bcrypt hash (leaf HostFn, vetted Go)
globalThis.__bcrypt.verify(pw, h) -> bool
globalThis.handle({ route, params, query, orgId, body }) -> { status, body }
```
- `__db` is bound **per dispatch** to exactly one tenant's SQLite file (selected
from the caller's validated org), so a route can only ever touch its own org's
rows.
- `__bcrypt` is a leaf-injected HostFn (bcrypt in vetted Go) — link passwords are
**hashed, never plaintext**.
- The per-tenant **schema** (DDL) lives in the Go leaf (`clients/dataroom/
schema.go`), run by gojabase on first open; this bundle only issues SQL.
- Admin routes run under the caller's validated cloud principal (org); public
viewer routes resolve their org from the link index the leaf maintains.
## Constraints (goja target)
Same as `@hanzo/plans`: ES2020, **no ESM** `import`/`export`, **no Node
builtins**, no `console` dependency. Authored directly as the artifact goja runs
— there is exactly one bundle, one way to build it.
## Vendoring
`hanzoai/cloud/clients/dataroom/bundle.js` is a **byte-identical vendored copy**
that cloud `go:embed`s (the task mandates `go:embed` here rather than importing a
Go module, since this repo is a TS app, not a Go module). Edit **this** file, then
re-vendor the copy into cloud.
+383
View File
@@ -0,0 +1,383 @@
// Hanzo Dataroom — goja bundle (read-WRITE, on clients/gojabase).
//
// SELF-CONTAINED, NO ESM, NO node: imports. The complete dataroom business
// logic (documents, data rooms, shareable links with access controls, viewers,
// per-page view analytics), authored to run verbatim inside the dop251/goja
// engine embedded in hanzoai/cloud (HIP-0106, task #101). It is the ESM-free
// port of the Papermark/Next.js API handlers this fold replaces — the Prisma
// data model becomes Base/SQLite tables (see the leaf's schema.go), the handlers
// become the route table below. No Postgres, no Next.js.
//
// Host contract (clients/gojabase injects these per dispatch; each dispatch runs
// inside ONE per-tenant SQLite transaction that commits iff status < 400):
// globalThis.__db.query(sql, args) -> [ {col: val, ...}, ... ]
// globalThis.__db.exec(sql, args) -> { changes, lastId }
// globalThis.__newId() -> collision-resistant id (crypto/rand)
// globalThis.__now() -> unix milliseconds
// globalThis.__bcrypt.hash(pw) -> bcrypt hash (leaf HostFn, vetted Go)
// globalThis.__bcrypt.verify(pw,h) -> bool
// globalThis.handle({ route, params, query, orgId, body }) -> { status, body }
//
// Document BYTES live in object storage (the cloud VFS/S3 seam) and are handled
// by the Go leaf; this bundle only ever stores/reads the opaque storage KEY.
//
// CANONICAL SOURCE: github.com/hanzoai/dataroom → goja/bundle.js. This file is a
// byte-identical VENDORED copy that clients/dataroom go:embeds (the task mandates
// go:embed here rather than importing a Go module, since the dataroom repo is a
// TS app, not a Go module). Edit the canonical copy, then re-vendor here.
(function () {
'use strict';
// === tiny helpers ==========================================================
function q(sql, args) { return globalThis.__db.query(sql, args || []); }
function e(sql, args) { return globalThis.__db.exec(sql, args || []); }
function id(prefix) { return (prefix || '') + globalThis.__newId(); }
function now() { return globalThis.__now(); }
function one(rows) { return rows && rows.length ? rows[0] : null; }
function jsonList(v) {
if (v == null) return [];
try { var a = JSON.parse(v); return Array.isArray(a) ? a : []; }
catch (_) { return []; }
}
function truthy(v) { return v === true || v === 1 || v === '1'; }
function asInt(v, dflt) {
if (v == null || v === '') return dflt == null ? null : dflt;
var n = parseInt(v, 10);
return isNaN(n) ? (dflt == null ? null : dflt) : n;
}
// err builds a route result carrying a non-200 status via __status. A >=400
// status also rolls back the dispatch transaction (gojabase), so a rejected
// request leaves the tenant DB untouched.
function err(status, message) { return { __status: status, error: message }; }
// === shapers (row -> API object) ===========================================
function documentOut(r) {
if (!r) return null;
return {
id: r.id, name: r.name, fileKey: r.file_key, contentType: r.content_type,
type: r.type, numPages: r.num_pages, fileSize: r.file_size,
createdAt: r.created_at, updatedAt: r.updated_at,
};
}
function dataroomOut(r) {
if (!r) return null;
return {
id: r.id, pId: r.p_id, name: r.name, description: r.description,
createdAt: r.created_at, updatedAt: r.updated_at,
};
}
function linkOut(r) {
if (!r) return null;
// password_hash is NEVER returned — only whether one is set.
return {
id: r.id, linkType: r.link_type, dataroomId: r.dataroom_id, documentId: r.document_id,
name: r.name, emailProtected: truthy(r.email_protected), hasPassword: !!r.password_hash,
allowList: jsonList(r.allow_list), allowDownload: truthy(r.allow_download),
expiresAt: r.expires_at, isArchived: truthy(r.is_archived),
createdAt: r.created_at, updatedAt: r.updated_at,
};
}
// emailAllowed reports whether email passes the link allow list. An empty list
// allows anyone (email gate only). Entries may be a full email ("a@b.com"), a
// "@domain.com" suffix, or a bare "domain.com".
function emailAllowed(email, allowList) {
if (!allowList || !allowList.length) return true;
if (!email) return false;
var lc = String(email).toLowerCase().trim();
var at = lc.lastIndexOf('@');
var domain = at >= 0 ? lc.slice(at + 1) : '';
for (var i = 0; i < allowList.length; i++) {
var entry = String(allowList[i]).toLowerCase().trim();
if (!entry) continue;
if (entry.indexOf('@') === 0) { if (domain === entry.slice(1)) return true; }
else if (entry.indexOf('@') > 0) { if (lc === entry) return true; }
else if (domain === entry) return true;
}
return false;
}
function linkExpired(r) {
return r.expires_at != null && Number(r.expires_at) > 0 && now() > Number(r.expires_at);
}
// === route handlers ========================================================
// Admin routes are org-scoped by the per-tenant DB gojabase selects; the Go
// leaf refuses any request without a validated principal before dispatching.
// Viewer routes run under the org resolved from the public link id.
var routes = {
// ---- documents ----------------------------------------------------------
// POST: the Go leaf has already stored the bytes on the object-storage seam
// and passes the resulting fileKey; this records the metadata row.
'documents.create': function (ctx) {
var b = ctx.body || {};
if (!b.name || !b.fileKey) return err(400, 'name and fileKey required');
var docId = id('doc_');
var t = now();
e('INSERT INTO document (id,name,file_key,content_type,type,num_pages,file_size,created_at,updated_at) ' +
'VALUES (?,?,?,?,?,?,?,?,?)',
[docId, String(b.name), String(b.fileKey), b.contentType || null, b.type || null,
asInt(b.numPages), asInt(b.fileSize), t, t]);
return { document: documentOut(one(q('SELECT * FROM document WHERE id=?', [docId]))) };
},
'documents.list': function () {
return { documents: q('SELECT * FROM document ORDER BY created_at DESC').map(documentOut) };
},
'documents.get': function (ctx) {
var r = one(q('SELECT * FROM document WHERE id=?', [ctx.params.id]));
if (!r) return err(404, 'document not found');
return { document: documentOut(r) };
},
// documents.file returns the storage key so the Go leaf can stream the bytes.
'documents.file': function (ctx) {
var r = one(q('SELECT * FROM document WHERE id=?', [ctx.params.id]));
if (!r) return err(404, 'document not found');
return { fileKey: r.file_key, contentType: r.content_type, name: r.name };
},
// ---- datarooms ----------------------------------------------------------
'datarooms.create': function (ctx) {
var b = ctx.body || {};
if (!b.name) return err(400, 'name required');
var roomId = id('room_');
var pId = 'dr_' + globalThis.__newId().slice(1, 13);
var t = now();
e('INSERT INTO dataroom (id,p_id,name,description,created_at,updated_at) VALUES (?,?,?,?,?,?)',
[roomId, pId, String(b.name), b.description || null, t, t]);
return { dataroom: dataroomOut(one(q('SELECT * FROM dataroom WHERE id=?', [roomId]))) };
},
'datarooms.list': function () {
return { datarooms: q('SELECT * FROM dataroom ORDER BY created_at DESC').map(dataroomOut) };
},
'datarooms.get': function (ctx) {
var r = one(q('SELECT * FROM dataroom WHERE id=?', [ctx.params.id]));
if (!r) return err(404, 'dataroom not found');
var docs = q(
'SELECT dd.id AS dd_id, dd.order_index, doc.* FROM dataroom_document dd ' +
'JOIN document doc ON doc.id = dd.document_id WHERE dd.dataroom_id=? ' +
'ORDER BY dd.order_index IS NULL, dd.order_index, dd.created_at', [r.id]);
var out = dataroomOut(r);
out.documents = docs.map(function (d) {
var doc = documentOut(d);
doc.dataroomDocumentId = d.dd_id;
doc.orderIndex = d.order_index;
return doc;
});
return { dataroom: out };
},
'datarooms.addDocument': function (ctx) {
var b = ctx.body || {};
var roomId = ctx.params.id;
var docId = b.documentId;
if (!docId) return err(400, 'documentId required');
if (!one(q('SELECT id FROM dataroom WHERE id=?', [roomId]))) return err(404, 'dataroom not found');
if (!one(q('SELECT id FROM document WHERE id=?', [docId]))) return err(404, 'document not found');
if (one(q('SELECT id FROM dataroom_document WHERE dataroom_id=? AND document_id=?', [roomId, docId]))) {
return err(409, 'document already in dataroom');
}
var ddId = id('dd_');
e('INSERT INTO dataroom_document (id,dataroom_id,document_id,order_index,created_at) VALUES (?,?,?,?,?)',
[ddId, roomId, docId, asInt(b.orderIndex), now()]);
return { dataroomDocumentId: ddId, dataroomId: roomId, documentId: docId };
},
// ---- links --------------------------------------------------------------
'links.create': function (ctx) {
var b = ctx.body || {};
var roomId = b.dataroomId || null;
var docId = b.documentId || null;
if (!roomId && !docId) return err(400, 'dataroomId or documentId required');
if (roomId && !one(q('SELECT id FROM dataroom WHERE id=?', [roomId]))) return err(404, 'dataroom not found');
if (docId && !one(q('SELECT id FROM document WHERE id=?', [docId]))) return err(404, 'document not found');
var pwHash = null;
if (b.password) pwHash = globalThis.__bcrypt.hash(String(b.password));
var allowList = Array.isArray(b.allowList) ? b.allowList : [];
var denyList = Array.isArray(b.denyList) ? b.denyList : [];
var linkId = id('link_');
var t = now();
var linkType = docId && !roomId ? 'DOCUMENT_LINK' : 'DATAROOM_LINK';
e('INSERT INTO link (id,link_type,dataroom_id,document_id,name,password_hash,email_protected,' +
'allow_list,deny_list,allow_download,expires_at,is_archived,created_at,updated_at) ' +
'VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
[linkId, linkType, roomId, docId, b.name || null, pwHash,
b.emailProtected === false ? 0 : 1, JSON.stringify(allowList), JSON.stringify(denyList),
b.allowDownload ? 1 : 0, asInt(b.expiresAt), 0, t, t]);
return { link: linkOut(one(q('SELECT * FROM link WHERE id=?', [linkId]))) };
},
'links.list': function () {
return { links: q('SELECT * FROM link WHERE is_archived=0 ORDER BY created_at DESC').map(linkOut) };
},
// ---- viewer surface (public) -------------------------------------------
// view.link returns the pre-auth metadata a visitor sees (what gates apply).
'view.link': function (ctx) {
var r = one(q('SELECT * FROM link WHERE id=?', [ctx.params.linkId]));
if (!r || truthy(r.is_archived)) return err(404, 'link not found');
var out = {
id: r.id, name: r.name, linkType: r.link_type,
emailProtected: truthy(r.email_protected), hasPassword: !!r.password_hash,
allowDownload: truthy(r.allow_download), expired: linkExpired(r),
};
if (r.dataroom_id) {
var room = one(q('SELECT * FROM dataroom WHERE id=?', [r.dataroom_id]));
if (room) out.dataroom = { id: room.id, pId: room.p_id, name: room.name, description: room.description };
}
if (r.document_id) {
var doc = one(q('SELECT * FROM document WHERE id=?', [r.document_id]));
if (doc) out.document = { id: doc.id, name: doc.name, numPages: doc.num_pages };
}
return { link: out };
},
// view.authenticate enforces the access controls and, on success, records a
// Viewer + a View (the analytics session) and returns the viewable payload.
'view.authenticate': function (ctx) {
var b = ctx.body || {};
var r = one(q('SELECT * FROM link WHERE id=?', [ctx.params.linkId]));
if (!r || truthy(r.is_archived)) return err(404, 'link not found');
if (linkExpired(r)) return err(403, 'link expired');
var email = b.email ? String(b.email).toLowerCase().trim() : '';
if (truthy(r.email_protected) && !email) return err(401, 'email required');
if (!emailAllowed(email, jsonList(r.allow_list))) return err(403, 'email not allowed');
if (r.password_hash) {
if (!b.password || !globalThis.__bcrypt.verify(String(b.password), r.password_hash)) {
return err(401, 'invalid password');
}
}
var viewerId = null;
if (email) {
var existing = one(q('SELECT * FROM viewer WHERE email=?', [email]));
if (existing) { viewerId = existing.id; }
else {
viewerId = id('viewer_');
var tv = now();
e('INSERT INTO viewer (id,email,verified,created_at,updated_at) VALUES (?,?,?,?,?)',
[viewerId, email, 0, tv, tv]);
}
}
var viewId = id('view_');
var viewType = r.dataroom_id ? 'DATAROOM_VIEW' : 'DOCUMENT_VIEW';
e('INSERT INTO view (id,link_id,dataroom_id,document_id,viewer_id,viewer_email,view_type,verified,viewed_at) ' +
'VALUES (?,?,?,?,?,?,?,?,?)',
[viewId, r.id, r.dataroom_id, r.document_id, viewerId, email || null, viewType, 0, now()]);
var payload = { viewId: viewId, viewerId: viewerId, allowDownload: truthy(r.allow_download) };
if (r.dataroom_id) {
var docs = q(
'SELECT dd.id AS dd_id, doc.* FROM dataroom_document dd ' +
'JOIN document doc ON doc.id = dd.document_id WHERE dd.dataroom_id=? ' +
'ORDER BY dd.order_index IS NULL, dd.order_index, dd.created_at', [r.dataroom_id]);
payload.documents = docs.map(function (d) {
return { id: d.id, dataroomDocumentId: d.dd_id, name: d.name, numPages: d.num_pages };
});
} else if (r.document_id) {
var single = one(q('SELECT * FROM document WHERE id=?', [r.document_id]));
if (single) payload.document = { id: single.id, name: single.name, numPages: single.num_pages };
}
return payload;
},
// view.recordPage records ONE per-page analytics event (the page-by-page
// tracking) against an existing View.
'view.recordPage': function (ctx) {
var b = ctx.body || {};
var linkId = ctx.params.linkId;
var v = one(q('SELECT * FROM view WHERE id=? AND link_id=?', [b.viewId, linkId]));
if (!v) return err(404, 'view not found');
if (b.pageNumber == null) return err(400, 'pageNumber required');
var pvId = id('pv_');
e('INSERT INTO page_view (id,view_id,link_id,document_id,dataroom_id,page_number,version_number,duration,viewed_at) ' +
'VALUES (?,?,?,?,?,?,?,?,?)',
[pvId, v.id, linkId, b.documentId || v.document_id, v.dataroom_id,
asInt(b.pageNumber, 0), asInt(b.versionNumber), asInt(b.duration, 0), now()]);
return { ok: true, id: pvId };
},
// view.file authorises a viewer download and returns the storage key.
'view.file': function (ctx) {
var linkId = ctx.params.linkId;
var docId = ctx.params.documentId;
var viewId = ctx.query.viewId;
var r = one(q('SELECT * FROM link WHERE id=?', [linkId]));
if (!r || truthy(r.is_archived)) return err(404, 'link not found');
if (!one(q('SELECT id FROM view WHERE id=? AND link_id=?', [viewId, linkId]))) return err(403, 'no active view');
if (truthy(ctx.query.download) && !truthy(r.allow_download)) return err(403, 'download disabled');
// The document must be reachable through this link (dataroom membership or
// the link's own document) — a viewer cannot address an unrelated doc.
if (r.dataroom_id) {
if (!one(q('SELECT id FROM dataroom_document WHERE dataroom_id=? AND document_id=?', [r.dataroom_id, docId]))) {
return err(404, 'document not in dataroom');
}
} else if (r.document_id !== docId) {
return err(404, 'document not on link');
}
var doc = one(q('SELECT * FROM document WHERE id=?', [docId]));
if (!doc) return err(404, 'document not found');
return { fileKey: doc.file_key, contentType: doc.content_type, name: doc.name };
},
// ---- analytics ----------------------------------------------------------
'analytics.link': function (ctx) {
var linkId = ctx.params.linkId;
if (!one(q('SELECT id FROM link WHERE id=?', [linkId]))) return err(404, 'link not found');
return linkAnalytics(linkId);
},
'analytics.dataroom': function (ctx) {
var roomId = ctx.params.dataroomId;
if (!one(q('SELECT id FROM dataroom WHERE id=?', [roomId]))) return err(404, 'dataroom not found');
var links = q('SELECT id FROM link WHERE dataroom_id=?', [roomId]);
var perLink = links.map(function (l) { return linkAnalytics(l.id); });
var totalViews = 0, totalPageViews = 0;
perLink.forEach(function (a) { totalViews += a.totalViews; totalPageViews += a.totalPageViews; });
return { dataroomId: roomId, totalViews: totalViews, totalPageViews: totalPageViews, links: perLink };
},
};
function linkAnalytics(linkId) {
var totalViews = Number((one(q('SELECT COUNT(*) AS n FROM view WHERE link_id=?', [linkId])) || {}).n || 0);
var totalPageViews = Number((one(q('SELECT COUNT(*) AS n FROM page_view WHERE link_id=?', [linkId])) || {}).n || 0);
var pages = q(
'SELECT page_number, COUNT(*) AS views, SUM(duration) AS total_duration ' +
'FROM page_view WHERE link_id=? GROUP BY page_number ORDER BY page_number', [linkId]);
return {
linkId: linkId, totalViews: totalViews, totalPageViews: totalPageViews,
pages: pages.map(function (p) {
var v = Number(p.views || 0), d = Number(p.total_duration || 0);
return { pageNumber: Number(p.page_number), views: v, totalDuration: d, avgDuration: v ? Math.round(d / v) : 0 };
}),
};
}
// === dispatch entry (host calls this per request) ==========================
globalThis.handle = function (req) {
req = req || {};
var ctx = {
params: req.params || {},
query: req.query || {},
orgId: req.orgId || '',
body: req.body || null,
};
var fn = routes[req.route];
if (!fn) return { status: 404, body: { error: 'unknown dataroom route: ' + req.route } };
try {
var out = fn(ctx);
if (out && out.__status) {
var status = out.__status;
delete out.__status;
return { status: status, body: out };
}
return { status: 200, body: out };
} catch (ex) {
return { status: 500, body: { error: String(ex && ex.message || ex) } };
}
};
})();