feat: persist multi-board projects + add auto-save
The project save/load pipeline only persisted a single `board_type`, so
multi-board workspaces silently lost every board except the active one
on save, and wires referencing the dropped boards' IDs orphaned to the
canvas corner on reload. An audit of the production backup found 74/306
projects (24%) with at least one orphaned wire and 174/301 non-trivial
projects whose code was still the default Blink template — strong signal
that users save once and never re-save.
Backend
- Add `boards_json` column on `projects` with idempotent ALTER TABLE in
the lifespan migration list.
- New `FileGroup` schema + `file_groups` array on
ProjectCreate/Update/Response. Legacy `files`/`code` kept for back-compat.
- `project_files.py` now uses `{pid}/{groupId}/{filename}` subdirs via
`read_groups`/`write_groups`. Legacy flat layouts are auto-promoted on
read; legacy single-list `files` only updates the active group, leaving
other boards' files intact.
- `_persist_files_from_body` honors file_groups → files → code priority.
Frontend
- `useSimulatorStore.addBoard` accepts an optional `explicitId` so
saved board IDs can be restored verbatim (wires reference IDs literally).
- New `loadProjectState({boards, fileGroups, components, wires,
activeBoardId})` action: tears down current boards, recreates from the
payload, restores file groups atomically, recalculates wire positions
on the next frame, and refreshes the Interconnect.
- `useEditorStore.replaceFileGroups` for atomic multi-group restore.
- `SaveProjectModal` and `ProjectByIdPage`/`ProjectPage` now go through
`buildSavePayload` / `buildLoadPayload` (handles pre-backfill projects
by synthesising a default board from `board_type`).
Auto-save (#useAutoSaveProject hook)
- 2.5s debounced silent PUT triggered ONLY when an authenticated user
has a `currentProject` with a UUID. State hash detects real changes
vs. UI-only churn; baseline is reset on project load so the just-loaded
state isn't immediately re-saved.
- `beforeunload` flush via `fetch keepalive: true` (supports PUT +
credentials, survives unload).
- Compact status indicator in `AppHeader` (idle/dirty/saving/saved/error).
Backfill script (one-off, idempotent)
- `backend/scripts/backfill_boards_2026_05.py` populates `boards_json`
for legacy projects. Heuristic per project, based on which board IDs
the wires reference:
Case A — wires only ref 'arduino-uno' but board_type ≠ uno:
rename id→board_type and rewrite wire endpoints.
Case B — single-board normal: keep verbatim.
Case C — multi-board: recreate one board per distinct ref, infer
kind by stripping trailing -N suffix.
Also moves any flat files into the active board's group subdir.
Stdlib-only, runs from host or `docker exec`.
Docker
- `Dockerfile.standalone` now copies `backend/scripts/` into the image
so the backfill is callable via `docker exec velxio-app python
/app/scripts/backfill_boards_2026_05.py --apply`.
Verified locally on the restored production backup (363 projects):
33 Case A, 316 Case B, 14 Case C, 135 wire endpoints renamed, 0 orphans.
Re-running the script after apply skips all 363 (idempotent).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:43:33 +07:00
|
|
|
import json
|
2026-03-06 20:14:50 +07:00
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
feat(metrics): add usage analytics dashboard with country tracking
Track per-user, per-project, and per-board usage to inform pricing tier
decisions. Adds an admin dashboard with KPIs (DAU/WAU/MAU, totals,
success rate), time-series charts for compiles/runs, board family +
FQBN breakdowns, "board diversity" pie chart (key freemium signal),
top users/projects, and per-country breakdown via Cloudflare's
CF-IPCountry header. Admin can now also view private projects.
Backend:
- New UsageEvent table (append-only event log with user_id, project_id,
event_type, board_fqbn/family, country, error_kind, duration_ms)
- Aggregate counters on User (total_compiles/runs/errors, last_active,
signup_country, last_country) and Project (compile/run/update counts,
last_compiled/run timestamps) kept in sync by MetricsService for O(1)
dashboard reads
- 10 admin endpoints under /api/admin/metrics/{overview, timeseries,
boards, board-diversity, top-users, top-projects, countries,
users/{id}, projects/{id}}
- POST /api/metrics/run for client-side run telemetry
- Country detection via cf-ipcountry header (no DB / no API calls)
- Auto-migrations in lifespan for legacy DBs
Frontend:
- recharts-powered Dashboard tab with KPI cards and 4 charts
- New Boards tab with per-family + per-FQBN breakdown
- Country column with flag emoji on Users tab
- Top countries card on Dashboard
- compileCode now forwards project_id; Run button reports via WS
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 05:46:52 +07:00
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
2026-03-06 20:14:50 +07:00
|
|
|
from sqlalchemy import select
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
|
|
|
|
from app.core.dependencies import get_current_user, require_auth
|
|
|
|
|
from app.database.session import get_db
|
|
|
|
|
from app.models.project import Project
|
|
|
|
|
from app.models.user import User
|
feat: persist multi-board projects + add auto-save
The project save/load pipeline only persisted a single `board_type`, so
multi-board workspaces silently lost every board except the active one
on save, and wires referencing the dropped boards' IDs orphaned to the
canvas corner on reload. An audit of the production backup found 74/306
projects (24%) with at least one orphaned wire and 174/301 non-trivial
projects whose code was still the default Blink template — strong signal
that users save once and never re-save.
Backend
- Add `boards_json` column on `projects` with idempotent ALTER TABLE in
the lifespan migration list.
- New `FileGroup` schema + `file_groups` array on
ProjectCreate/Update/Response. Legacy `files`/`code` kept for back-compat.
- `project_files.py` now uses `{pid}/{groupId}/{filename}` subdirs via
`read_groups`/`write_groups`. Legacy flat layouts are auto-promoted on
read; legacy single-list `files` only updates the active group, leaving
other boards' files intact.
- `_persist_files_from_body` honors file_groups → files → code priority.
Frontend
- `useSimulatorStore.addBoard` accepts an optional `explicitId` so
saved board IDs can be restored verbatim (wires reference IDs literally).
- New `loadProjectState({boards, fileGroups, components, wires,
activeBoardId})` action: tears down current boards, recreates from the
payload, restores file groups atomically, recalculates wire positions
on the next frame, and refreshes the Interconnect.
- `useEditorStore.replaceFileGroups` for atomic multi-group restore.
- `SaveProjectModal` and `ProjectByIdPage`/`ProjectPage` now go through
`buildSavePayload` / `buildLoadPayload` (handles pre-backfill projects
by synthesising a default board from `board_type`).
Auto-save (#useAutoSaveProject hook)
- 2.5s debounced silent PUT triggered ONLY when an authenticated user
has a `currentProject` with a UUID. State hash detects real changes
vs. UI-only churn; baseline is reset on project load so the just-loaded
state isn't immediately re-saved.
- `beforeunload` flush via `fetch keepalive: true` (supports PUT +
credentials, survives unload).
- Compact status indicator in `AppHeader` (idle/dirty/saving/saved/error).
Backfill script (one-off, idempotent)
- `backend/scripts/backfill_boards_2026_05.py` populates `boards_json`
for legacy projects. Heuristic per project, based on which board IDs
the wires reference:
Case A — wires only ref 'arduino-uno' but board_type ≠ uno:
rename id→board_type and rewrite wire endpoints.
Case B — single-board normal: keep verbatim.
Case C — multi-board: recreate one board per distinct ref, infer
kind by stripping trailing -N suffix.
Also moves any flat files into the active board's group subdir.
Stdlib-only, runs from host or `docker exec`.
Docker
- `Dockerfile.standalone` now copies `backend/scripts/` into the image
so the backfill is callable via `docker exec velxio-app python
/app/scripts/backfill_boards_2026_05.py --apply`.
Verified locally on the restored production backup (363 projects):
33 Case A, 316 Case B, 14 Case C, 135 wire endpoints renamed, 0 orphans.
Re-running the script after apply skips all 363 (idempotent).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:43:33 +07:00
|
|
|
from app.schemas.project import (
|
|
|
|
|
FileGroup,
|
|
|
|
|
ProjectCreateRequest,
|
|
|
|
|
ProjectResponse,
|
|
|
|
|
ProjectUpdateRequest,
|
|
|
|
|
SketchFile,
|
|
|
|
|
)
|
feat(metrics): add usage analytics dashboard with country tracking
Track per-user, per-project, and per-board usage to inform pricing tier
decisions. Adds an admin dashboard with KPIs (DAU/WAU/MAU, totals,
success rate), time-series charts for compiles/runs, board family +
FQBN breakdowns, "board diversity" pie chart (key freemium signal),
top users/projects, and per-country breakdown via Cloudflare's
CF-IPCountry header. Admin can now also view private projects.
Backend:
- New UsageEvent table (append-only event log with user_id, project_id,
event_type, board_fqbn/family, country, error_kind, duration_ms)
- Aggregate counters on User (total_compiles/runs/errors, last_active,
signup_country, last_country) and Project (compile/run/update counts,
last_compiled/run timestamps) kept in sync by MetricsService for O(1)
dashboard reads
- 10 admin endpoints under /api/admin/metrics/{overview, timeseries,
boards, board-diversity, top-users, top-projects, countries,
users/{id}, projects/{id}}
- POST /api/metrics/run for client-side run telemetry
- Country detection via cf-ipcountry header (no DB / no API calls)
- Auto-migrations in lifespan for legacy DBs
Frontend:
- recharts-powered Dashboard tab with KPI cards and 4 charts
- New Boards tab with per-family + per-FQBN breakdown
- Country column with flag emoji on Users tab
- Top countries card on Dashboard
- compileCode now forwards project_id; Run button reports via WS
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 05:46:52 +07:00
|
|
|
from app.services.metrics import record_project_open, record_save
|
feat: persist multi-board projects + add auto-save
The project save/load pipeline only persisted a single `board_type`, so
multi-board workspaces silently lost every board except the active one
on save, and wires referencing the dropped boards' IDs orphaned to the
canvas corner on reload. An audit of the production backup found 74/306
projects (24%) with at least one orphaned wire and 174/301 non-trivial
projects whose code was still the default Blink template — strong signal
that users save once and never re-save.
Backend
- Add `boards_json` column on `projects` with idempotent ALTER TABLE in
the lifespan migration list.
- New `FileGroup` schema + `file_groups` array on
ProjectCreate/Update/Response. Legacy `files`/`code` kept for back-compat.
- `project_files.py` now uses `{pid}/{groupId}/{filename}` subdirs via
`read_groups`/`write_groups`. Legacy flat layouts are auto-promoted on
read; legacy single-list `files` only updates the active group, leaving
other boards' files intact.
- `_persist_files_from_body` honors file_groups → files → code priority.
Frontend
- `useSimulatorStore.addBoard` accepts an optional `explicitId` so
saved board IDs can be restored verbatim (wires reference IDs literally).
- New `loadProjectState({boards, fileGroups, components, wires,
activeBoardId})` action: tears down current boards, recreates from the
payload, restores file groups atomically, recalculates wire positions
on the next frame, and refreshes the Interconnect.
- `useEditorStore.replaceFileGroups` for atomic multi-group restore.
- `SaveProjectModal` and `ProjectByIdPage`/`ProjectPage` now go through
`buildSavePayload` / `buildLoadPayload` (handles pre-backfill projects
by synthesising a default board from `board_type`).
Auto-save (#useAutoSaveProject hook)
- 2.5s debounced silent PUT triggered ONLY when an authenticated user
has a `currentProject` with a UUID. State hash detects real changes
vs. UI-only churn; baseline is reset on project load so the just-loaded
state isn't immediately re-saved.
- `beforeunload` flush via `fetch keepalive: true` (supports PUT +
credentials, survives unload).
- Compact status indicator in `AppHeader` (idle/dirty/saving/saved/error).
Backfill script (one-off, idempotent)
- `backend/scripts/backfill_boards_2026_05.py` populates `boards_json`
for legacy projects. Heuristic per project, based on which board IDs
the wires reference:
Case A — wires only ref 'arduino-uno' but board_type ≠ uno:
rename id→board_type and rewrite wire endpoints.
Case B — single-board normal: keep verbatim.
Case C — multi-board: recreate one board per distinct ref, infer
kind by stripping trailing -N suffix.
Also moves any flat files into the active board's group subdir.
Stdlib-only, runs from host or `docker exec`.
Docker
- `Dockerfile.standalone` now copies `backend/scripts/` into the image
so the backfill is callable via `docker exec velxio-app python
/app/scripts/backfill_boards_2026_05.py --apply`.
Verified locally on the restored production backup (363 projects):
33 Case A, 316 Case B, 14 Case C, 135 wire endpoints renamed, 0 orphans.
Re-running the script after apply skips all 363 (idempotent).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:43:33 +07:00
|
|
|
from app.services.project_files import (
|
|
|
|
|
LEGACY_GROUP_KEY,
|
|
|
|
|
delete_files,
|
|
|
|
|
read_groups,
|
|
|
|
|
write_groups,
|
|
|
|
|
)
|
2026-03-06 20:14:50 +07:00
|
|
|
from app.utils.slug import slugify
|
|
|
|
|
|
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
|
|
|
|
|
feat: persist multi-board projects + add auto-save
The project save/load pipeline only persisted a single `board_type`, so
multi-board workspaces silently lost every board except the active one
on save, and wires referencing the dropped boards' IDs orphaned to the
canvas corner on reload. An audit of the production backup found 74/306
projects (24%) with at least one orphaned wire and 174/301 non-trivial
projects whose code was still the default Blink template — strong signal
that users save once and never re-save.
Backend
- Add `boards_json` column on `projects` with idempotent ALTER TABLE in
the lifespan migration list.
- New `FileGroup` schema + `file_groups` array on
ProjectCreate/Update/Response. Legacy `files`/`code` kept for back-compat.
- `project_files.py` now uses `{pid}/{groupId}/{filename}` subdirs via
`read_groups`/`write_groups`. Legacy flat layouts are auto-promoted on
read; legacy single-list `files` only updates the active group, leaving
other boards' files intact.
- `_persist_files_from_body` honors file_groups → files → code priority.
Frontend
- `useSimulatorStore.addBoard` accepts an optional `explicitId` so
saved board IDs can be restored verbatim (wires reference IDs literally).
- New `loadProjectState({boards, fileGroups, components, wires,
activeBoardId})` action: tears down current boards, recreates from the
payload, restores file groups atomically, recalculates wire positions
on the next frame, and refreshes the Interconnect.
- `useEditorStore.replaceFileGroups` for atomic multi-group restore.
- `SaveProjectModal` and `ProjectByIdPage`/`ProjectPage` now go through
`buildSavePayload` / `buildLoadPayload` (handles pre-backfill projects
by synthesising a default board from `board_type`).
Auto-save (#useAutoSaveProject hook)
- 2.5s debounced silent PUT triggered ONLY when an authenticated user
has a `currentProject` with a UUID. State hash detects real changes
vs. UI-only churn; baseline is reset on project load so the just-loaded
state isn't immediately re-saved.
- `beforeunload` flush via `fetch keepalive: true` (supports PUT +
credentials, survives unload).
- Compact status indicator in `AppHeader` (idle/dirty/saving/saved/error).
Backfill script (one-off, idempotent)
- `backend/scripts/backfill_boards_2026_05.py` populates `boards_json`
for legacy projects. Heuristic per project, based on which board IDs
the wires reference:
Case A — wires only ref 'arduino-uno' but board_type ≠ uno:
rename id→board_type and rewrite wire endpoints.
Case B — single-board normal: keep verbatim.
Case C — multi-board: recreate one board per distinct ref, infer
kind by stripping trailing -N suffix.
Also moves any flat files into the active board's group subdir.
Stdlib-only, runs from host or `docker exec`.
Docker
- `Dockerfile.standalone` now copies `backend/scripts/` into the image
so the backfill is callable via `docker exec velxio-app python
/app/scripts/backfill_boards_2026_05.py --apply`.
Verified locally on the restored production backup (363 projects):
33 Case A, 316 Case B, 14 Case C, 135 wire endpoints renamed, 0 orphans.
Re-running the script after apply skips all 363 (idempotent).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:43:33 +07:00
|
|
|
def _active_group_id(project: Project) -> str:
|
|
|
|
|
"""Group ID that holds the active board's files. Used to label legacy
|
|
|
|
|
flat-layout files when promoting them into the multi-group response."""
|
|
|
|
|
# Try to extract activeFileGroupId from boards_json; fall back to a derived
|
|
|
|
|
# name based on board_type. This matches the frontend convention
|
|
|
|
|
# `group-${boardId}` (see useSimulatorStore.ts:615).
|
|
|
|
|
try:
|
|
|
|
|
boards = json.loads(project.boards_json or "[]")
|
|
|
|
|
if isinstance(boards, list) and boards:
|
|
|
|
|
first = boards[0]
|
|
|
|
|
if isinstance(first, dict):
|
|
|
|
|
gid = first.get("activeFileGroupId")
|
|
|
|
|
if isinstance(gid, str) and gid:
|
|
|
|
|
return gid
|
|
|
|
|
except (ValueError, TypeError):
|
|
|
|
|
pass
|
|
|
|
|
return f"group-{project.board_type or 'arduino-uno'}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _groups_for_project(project: Project) -> list[FileGroup]:
|
|
|
|
|
"""Load files from disk grouped per-board. Promotes legacy flat layouts
|
|
|
|
|
into a single group keyed off the project's active board id."""
|
|
|
|
|
raw = read_groups(project.id)
|
|
|
|
|
if not raw:
|
|
|
|
|
if project.code:
|
|
|
|
|
# No on-disk files but a legacy code field: synthesise a single
|
|
|
|
|
# sketch.ino under the active group.
|
|
|
|
|
return [
|
|
|
|
|
FileGroup(
|
|
|
|
|
groupId=_active_group_id(project),
|
|
|
|
|
files=[SketchFile(name="sketch.ino", content=project.code)],
|
|
|
|
|
)
|
|
|
|
|
]
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
groups: list[FileGroup] = []
|
|
|
|
|
if LEGACY_GROUP_KEY in raw:
|
|
|
|
|
legacy_files = raw.pop(LEGACY_GROUP_KEY)
|
|
|
|
|
groups.append(
|
|
|
|
|
FileGroup(
|
|
|
|
|
groupId=_active_group_id(project),
|
|
|
|
|
files=[SketchFile(**f) for f in legacy_files],
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
for gid, files in raw.items():
|
|
|
|
|
groups.append(
|
|
|
|
|
FileGroup(groupId=gid, files=[SketchFile(**f) for f in files])
|
|
|
|
|
)
|
|
|
|
|
return groups
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _files_for_active_board(groups: list[FileGroup], project: Project) -> list[SketchFile]:
|
|
|
|
|
"""Pick the SketchFile list of the active board (legacy `files` field)."""
|
|
|
|
|
if not groups:
|
|
|
|
|
return []
|
|
|
|
|
target = _active_group_id(project)
|
|
|
|
|
for g in groups:
|
|
|
|
|
if g.groupId == target:
|
|
|
|
|
return list(g.files)
|
|
|
|
|
return list(groups[0].files)
|
2026-03-07 06:38:06 +07:00
|
|
|
|
|
|
|
|
|
2026-03-06 20:14:50 +07:00
|
|
|
def _to_response(project: Project, owner_username: str) -> ProjectResponse:
|
feat: persist multi-board projects + add auto-save
The project save/load pipeline only persisted a single `board_type`, so
multi-board workspaces silently lost every board except the active one
on save, and wires referencing the dropped boards' IDs orphaned to the
canvas corner on reload. An audit of the production backup found 74/306
projects (24%) with at least one orphaned wire and 174/301 non-trivial
projects whose code was still the default Blink template — strong signal
that users save once and never re-save.
Backend
- Add `boards_json` column on `projects` with idempotent ALTER TABLE in
the lifespan migration list.
- New `FileGroup` schema + `file_groups` array on
ProjectCreate/Update/Response. Legacy `files`/`code` kept for back-compat.
- `project_files.py` now uses `{pid}/{groupId}/{filename}` subdirs via
`read_groups`/`write_groups`. Legacy flat layouts are auto-promoted on
read; legacy single-list `files` only updates the active group, leaving
other boards' files intact.
- `_persist_files_from_body` honors file_groups → files → code priority.
Frontend
- `useSimulatorStore.addBoard` accepts an optional `explicitId` so
saved board IDs can be restored verbatim (wires reference IDs literally).
- New `loadProjectState({boards, fileGroups, components, wires,
activeBoardId})` action: tears down current boards, recreates from the
payload, restores file groups atomically, recalculates wire positions
on the next frame, and refreshes the Interconnect.
- `useEditorStore.replaceFileGroups` for atomic multi-group restore.
- `SaveProjectModal` and `ProjectByIdPage`/`ProjectPage` now go through
`buildSavePayload` / `buildLoadPayload` (handles pre-backfill projects
by synthesising a default board from `board_type`).
Auto-save (#useAutoSaveProject hook)
- 2.5s debounced silent PUT triggered ONLY when an authenticated user
has a `currentProject` with a UUID. State hash detects real changes
vs. UI-only churn; baseline is reset on project load so the just-loaded
state isn't immediately re-saved.
- `beforeunload` flush via `fetch keepalive: true` (supports PUT +
credentials, survives unload).
- Compact status indicator in `AppHeader` (idle/dirty/saving/saved/error).
Backfill script (one-off, idempotent)
- `backend/scripts/backfill_boards_2026_05.py` populates `boards_json`
for legacy projects. Heuristic per project, based on which board IDs
the wires reference:
Case A — wires only ref 'arduino-uno' but board_type ≠ uno:
rename id→board_type and rewrite wire endpoints.
Case B — single-board normal: keep verbatim.
Case C — multi-board: recreate one board per distinct ref, infer
kind by stripping trailing -N suffix.
Also moves any flat files into the active board's group subdir.
Stdlib-only, runs from host or `docker exec`.
Docker
- `Dockerfile.standalone` now copies `backend/scripts/` into the image
so the backfill is callable via `docker exec velxio-app python
/app/scripts/backfill_boards_2026_05.py --apply`.
Verified locally on the restored production backup (363 projects):
33 Case A, 316 Case B, 14 Case C, 135 wire endpoints renamed, 0 orphans.
Re-running the script after apply skips all 363 (idempotent).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:43:33 +07:00
|
|
|
groups = _groups_for_project(project)
|
2026-03-06 20:14:50 +07:00
|
|
|
return ProjectResponse(
|
|
|
|
|
id=project.id,
|
|
|
|
|
name=project.name,
|
|
|
|
|
slug=project.slug,
|
|
|
|
|
description=project.description,
|
|
|
|
|
is_public=project.is_public,
|
|
|
|
|
board_type=project.board_type,
|
feat: persist multi-board projects + add auto-save
The project save/load pipeline only persisted a single `board_type`, so
multi-board workspaces silently lost every board except the active one
on save, and wires referencing the dropped boards' IDs orphaned to the
canvas corner on reload. An audit of the production backup found 74/306
projects (24%) with at least one orphaned wire and 174/301 non-trivial
projects whose code was still the default Blink template — strong signal
that users save once and never re-save.
Backend
- Add `boards_json` column on `projects` with idempotent ALTER TABLE in
the lifespan migration list.
- New `FileGroup` schema + `file_groups` array on
ProjectCreate/Update/Response. Legacy `files`/`code` kept for back-compat.
- `project_files.py` now uses `{pid}/{groupId}/{filename}` subdirs via
`read_groups`/`write_groups`. Legacy flat layouts are auto-promoted on
read; legacy single-list `files` only updates the active group, leaving
other boards' files intact.
- `_persist_files_from_body` honors file_groups → files → code priority.
Frontend
- `useSimulatorStore.addBoard` accepts an optional `explicitId` so
saved board IDs can be restored verbatim (wires reference IDs literally).
- New `loadProjectState({boards, fileGroups, components, wires,
activeBoardId})` action: tears down current boards, recreates from the
payload, restores file groups atomically, recalculates wire positions
on the next frame, and refreshes the Interconnect.
- `useEditorStore.replaceFileGroups` for atomic multi-group restore.
- `SaveProjectModal` and `ProjectByIdPage`/`ProjectPage` now go through
`buildSavePayload` / `buildLoadPayload` (handles pre-backfill projects
by synthesising a default board from `board_type`).
Auto-save (#useAutoSaveProject hook)
- 2.5s debounced silent PUT triggered ONLY when an authenticated user
has a `currentProject` with a UUID. State hash detects real changes
vs. UI-only churn; baseline is reset on project load so the just-loaded
state isn't immediately re-saved.
- `beforeunload` flush via `fetch keepalive: true` (supports PUT +
credentials, survives unload).
- Compact status indicator in `AppHeader` (idle/dirty/saving/saved/error).
Backfill script (one-off, idempotent)
- `backend/scripts/backfill_boards_2026_05.py` populates `boards_json`
for legacy projects. Heuristic per project, based on which board IDs
the wires reference:
Case A — wires only ref 'arduino-uno' but board_type ≠ uno:
rename id→board_type and rewrite wire endpoints.
Case B — single-board normal: keep verbatim.
Case C — multi-board: recreate one board per distinct ref, infer
kind by stripping trailing -N suffix.
Also moves any flat files into the active board's group subdir.
Stdlib-only, runs from host or `docker exec`.
Docker
- `Dockerfile.standalone` now copies `backend/scripts/` into the image
so the backfill is callable via `docker exec velxio-app python
/app/scripts/backfill_boards_2026_05.py --apply`.
Verified locally on the restored production backup (363 projects):
33 Case A, 316 Case B, 14 Case C, 135 wire endpoints renamed, 0 orphans.
Re-running the script after apply skips all 363 (idempotent).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:43:33 +07:00
|
|
|
files=_files_for_active_board(groups, project),
|
|
|
|
|
file_groups=groups,
|
2026-03-06 20:14:50 +07:00
|
|
|
code=project.code,
|
|
|
|
|
components_json=project.components_json,
|
|
|
|
|
wires_json=project.wires_json,
|
feat: persist multi-board projects + add auto-save
The project save/load pipeline only persisted a single `board_type`, so
multi-board workspaces silently lost every board except the active one
on save, and wires referencing the dropped boards' IDs orphaned to the
canvas corner on reload. An audit of the production backup found 74/306
projects (24%) with at least one orphaned wire and 174/301 non-trivial
projects whose code was still the default Blink template — strong signal
that users save once and never re-save.
Backend
- Add `boards_json` column on `projects` with idempotent ALTER TABLE in
the lifespan migration list.
- New `FileGroup` schema + `file_groups` array on
ProjectCreate/Update/Response. Legacy `files`/`code` kept for back-compat.
- `project_files.py` now uses `{pid}/{groupId}/{filename}` subdirs via
`read_groups`/`write_groups`. Legacy flat layouts are auto-promoted on
read; legacy single-list `files` only updates the active group, leaving
other boards' files intact.
- `_persist_files_from_body` honors file_groups → files → code priority.
Frontend
- `useSimulatorStore.addBoard` accepts an optional `explicitId` so
saved board IDs can be restored verbatim (wires reference IDs literally).
- New `loadProjectState({boards, fileGroups, components, wires,
activeBoardId})` action: tears down current boards, recreates from the
payload, restores file groups atomically, recalculates wire positions
on the next frame, and refreshes the Interconnect.
- `useEditorStore.replaceFileGroups` for atomic multi-group restore.
- `SaveProjectModal` and `ProjectByIdPage`/`ProjectPage` now go through
`buildSavePayload` / `buildLoadPayload` (handles pre-backfill projects
by synthesising a default board from `board_type`).
Auto-save (#useAutoSaveProject hook)
- 2.5s debounced silent PUT triggered ONLY when an authenticated user
has a `currentProject` with a UUID. State hash detects real changes
vs. UI-only churn; baseline is reset on project load so the just-loaded
state isn't immediately re-saved.
- `beforeunload` flush via `fetch keepalive: true` (supports PUT +
credentials, survives unload).
- Compact status indicator in `AppHeader` (idle/dirty/saving/saved/error).
Backfill script (one-off, idempotent)
- `backend/scripts/backfill_boards_2026_05.py` populates `boards_json`
for legacy projects. Heuristic per project, based on which board IDs
the wires reference:
Case A — wires only ref 'arduino-uno' but board_type ≠ uno:
rename id→board_type and rewrite wire endpoints.
Case B — single-board normal: keep verbatim.
Case C — multi-board: recreate one board per distinct ref, infer
kind by stripping trailing -N suffix.
Also moves any flat files into the active board's group subdir.
Stdlib-only, runs from host or `docker exec`.
Docker
- `Dockerfile.standalone` now copies `backend/scripts/` into the image
so the backfill is callable via `docker exec velxio-app python
/app/scripts/backfill_boards_2026_05.py --apply`.
Verified locally on the restored production backup (363 projects):
33 Case A, 316 Case B, 14 Case C, 135 wire endpoints renamed, 0 orphans.
Re-running the script after apply skips all 363 (idempotent).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:43:33 +07:00
|
|
|
boards_json=project.boards_json or "[]",
|
2026-03-06 20:14:50 +07:00
|
|
|
owner_username=owner_username,
|
|
|
|
|
created_at=project.created_at,
|
|
|
|
|
updated_at=project.updated_at,
|
feat(metrics): add usage analytics dashboard with country tracking
Track per-user, per-project, and per-board usage to inform pricing tier
decisions. Adds an admin dashboard with KPIs (DAU/WAU/MAU, totals,
success rate), time-series charts for compiles/runs, board family +
FQBN breakdowns, "board diversity" pie chart (key freemium signal),
top users/projects, and per-country breakdown via Cloudflare's
CF-IPCountry header. Admin can now also view private projects.
Backend:
- New UsageEvent table (append-only event log with user_id, project_id,
event_type, board_fqbn/family, country, error_kind, duration_ms)
- Aggregate counters on User (total_compiles/runs/errors, last_active,
signup_country, last_country) and Project (compile/run/update counts,
last_compiled/run timestamps) kept in sync by MetricsService for O(1)
dashboard reads
- 10 admin endpoints under /api/admin/metrics/{overview, timeseries,
boards, board-diversity, top-users, top-projects, countries,
users/{id}, projects/{id}}
- POST /api/metrics/run for client-side run telemetry
- Country detection via cf-ipcountry header (no DB / no API calls)
- Auto-migrations in lifespan for legacy DBs
Frontend:
- recharts-powered Dashboard tab with KPI cards and 4 charts
- New Boards tab with per-family + per-FQBN breakdown
- Country column with flag emoji on Users tab
- Top countries card on Dashboard
- compileCode now forwards project_id; Run button reports via WS
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 05:46:52 +07:00
|
|
|
compile_count=project.compile_count,
|
|
|
|
|
compile_error_count=project.compile_error_count,
|
|
|
|
|
run_count=project.run_count,
|
|
|
|
|
update_count=project.update_count,
|
|
|
|
|
last_compiled_at=project.last_compiled_at,
|
|
|
|
|
last_run_at=project.last_run_at,
|
2026-03-06 20:14:50 +07:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
feat: persist multi-board projects + add auto-save
The project save/load pipeline only persisted a single `board_type`, so
multi-board workspaces silently lost every board except the active one
on save, and wires referencing the dropped boards' IDs orphaned to the
canvas corner on reload. An audit of the production backup found 74/306
projects (24%) with at least one orphaned wire and 174/301 non-trivial
projects whose code was still the default Blink template — strong signal
that users save once and never re-save.
Backend
- Add `boards_json` column on `projects` with idempotent ALTER TABLE in
the lifespan migration list.
- New `FileGroup` schema + `file_groups` array on
ProjectCreate/Update/Response. Legacy `files`/`code` kept for back-compat.
- `project_files.py` now uses `{pid}/{groupId}/{filename}` subdirs via
`read_groups`/`write_groups`. Legacy flat layouts are auto-promoted on
read; legacy single-list `files` only updates the active group, leaving
other boards' files intact.
- `_persist_files_from_body` honors file_groups → files → code priority.
Frontend
- `useSimulatorStore.addBoard` accepts an optional `explicitId` so
saved board IDs can be restored verbatim (wires reference IDs literally).
- New `loadProjectState({boards, fileGroups, components, wires,
activeBoardId})` action: tears down current boards, recreates from the
payload, restores file groups atomically, recalculates wire positions
on the next frame, and refreshes the Interconnect.
- `useEditorStore.replaceFileGroups` for atomic multi-group restore.
- `SaveProjectModal` and `ProjectByIdPage`/`ProjectPage` now go through
`buildSavePayload` / `buildLoadPayload` (handles pre-backfill projects
by synthesising a default board from `board_type`).
Auto-save (#useAutoSaveProject hook)
- 2.5s debounced silent PUT triggered ONLY when an authenticated user
has a `currentProject` with a UUID. State hash detects real changes
vs. UI-only churn; baseline is reset on project load so the just-loaded
state isn't immediately re-saved.
- `beforeunload` flush via `fetch keepalive: true` (supports PUT +
credentials, survives unload).
- Compact status indicator in `AppHeader` (idle/dirty/saving/saved/error).
Backfill script (one-off, idempotent)
- `backend/scripts/backfill_boards_2026_05.py` populates `boards_json`
for legacy projects. Heuristic per project, based on which board IDs
the wires reference:
Case A — wires only ref 'arduino-uno' but board_type ≠ uno:
rename id→board_type and rewrite wire endpoints.
Case B — single-board normal: keep verbatim.
Case C — multi-board: recreate one board per distinct ref, infer
kind by stripping trailing -N suffix.
Also moves any flat files into the active board's group subdir.
Stdlib-only, runs from host or `docker exec`.
Docker
- `Dockerfile.standalone` now copies `backend/scripts/` into the image
so the backfill is callable via `docker exec velxio-app python
/app/scripts/backfill_boards_2026_05.py --apply`.
Verified locally on the restored production backup (363 projects):
33 Case A, 316 Case B, 14 Case C, 135 wire endpoints renamed, 0 orphans.
Re-running the script after apply skips all 363 (idempotent).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:43:33 +07:00
|
|
|
def _persist_files_from_body(
|
|
|
|
|
project: Project,
|
|
|
|
|
body: "ProjectCreateRequest | ProjectUpdateRequest",
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Write the request body's file representation to disk.
|
|
|
|
|
|
|
|
|
|
Priority:
|
|
|
|
|
1. ``body.file_groups`` (full multi-board layout) — full replace.
|
|
|
|
|
2. ``body.files`` (legacy single-board list) — update only the active
|
|
|
|
|
group, preserve other groups on disk.
|
|
|
|
|
3. ``body.code`` (legacy code field, create only) — wrap as sketch.ino
|
|
|
|
|
in the active group.
|
|
|
|
|
"""
|
|
|
|
|
if body.file_groups is not None:
|
|
|
|
|
groups = {
|
|
|
|
|
g.groupId: [f.model_dump() for f in g.files] for g in body.file_groups
|
|
|
|
|
}
|
|
|
|
|
write_groups(project.id, groups)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
legacy_files: list[dict] | None = None
|
|
|
|
|
if body.files is not None:
|
|
|
|
|
legacy_files = [f.model_dump() for f in body.files]
|
|
|
|
|
elif getattr(body, "code", None):
|
|
|
|
|
legacy_files = [{"name": "sketch.ino", "content": body.code}]
|
|
|
|
|
|
|
|
|
|
if not legacy_files:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# Update only the active group, leaving any other groups (multi-board
|
|
|
|
|
# projects) intact on disk.
|
|
|
|
|
active_gid = _active_group_id(project)
|
|
|
|
|
existing = read_groups(project.id)
|
|
|
|
|
existing.pop(LEGACY_GROUP_KEY, None)
|
|
|
|
|
existing[active_gid] = legacy_files
|
|
|
|
|
write_groups(project.id, existing)
|
|
|
|
|
|
|
|
|
|
|
2026-03-06 20:14:50 +07:00
|
|
|
async def _unique_slug(db: AsyncSession, user_id: str, base_slug: str) -> str:
|
|
|
|
|
slug = base_slug or "project"
|
|
|
|
|
counter = 1
|
|
|
|
|
while True:
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(Project).where(Project.user_id == user_id, Project.slug == slug)
|
|
|
|
|
)
|
|
|
|
|
if not result.scalar_one_or_none():
|
|
|
|
|
return slug
|
|
|
|
|
slug = f"{base_slug}-{counter}"
|
|
|
|
|
counter += 1
|
|
|
|
|
|
|
|
|
|
|
2026-03-07 06:38:06 +07:00
|
|
|
# ── My projects (literal route — must be before /{project_id}) ───────────────
|
|
|
|
|
|
2026-03-06 20:14:50 +07:00
|
|
|
@router.get("/projects/me", response_model=list[ProjectResponse])
|
|
|
|
|
async def my_projects(
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
user: User = Depends(require_auth),
|
|
|
|
|
):
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(Project).where(Project.user_id == user.id).order_by(Project.updated_at.desc())
|
|
|
|
|
)
|
|
|
|
|
projects = result.scalars().all()
|
|
|
|
|
return [_to_response(p, user.username) for p in projects]
|
|
|
|
|
|
|
|
|
|
|
2026-03-07 06:38:06 +07:00
|
|
|
# ── GET by ID ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
@router.get("/projects/{project_id}", response_model=ProjectResponse)
|
|
|
|
|
async def get_project_by_id(
|
|
|
|
|
project_id: str,
|
feat(metrics): add usage analytics dashboard with country tracking
Track per-user, per-project, and per-board usage to inform pricing tier
decisions. Adds an admin dashboard with KPIs (DAU/WAU/MAU, totals,
success rate), time-series charts for compiles/runs, board family +
FQBN breakdowns, "board diversity" pie chart (key freemium signal),
top users/projects, and per-country breakdown via Cloudflare's
CF-IPCountry header. Admin can now also view private projects.
Backend:
- New UsageEvent table (append-only event log with user_id, project_id,
event_type, board_fqbn/family, country, error_kind, duration_ms)
- Aggregate counters on User (total_compiles/runs/errors, last_active,
signup_country, last_country) and Project (compile/run/update counts,
last_compiled/run timestamps) kept in sync by MetricsService for O(1)
dashboard reads
- 10 admin endpoints under /api/admin/metrics/{overview, timeseries,
boards, board-diversity, top-users, top-projects, countries,
users/{id}, projects/{id}}
- POST /api/metrics/run for client-side run telemetry
- Country detection via cf-ipcountry header (no DB / no API calls)
- Auto-migrations in lifespan for legacy DBs
Frontend:
- recharts-powered Dashboard tab with KPI cards and 4 charts
- New Boards tab with per-family + per-FQBN breakdown
- Country column with flag emoji on Users tab
- Top countries card on Dashboard
- compileCode now forwards project_id; Run button reports via WS
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 05:46:52 +07:00
|
|
|
request: Request,
|
2026-03-07 06:38:06 +07:00
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
current_user: User | None = Depends(get_current_user),
|
|
|
|
|
):
|
|
|
|
|
result = await db.execute(select(Project).where(Project.id == project_id))
|
|
|
|
|
project = result.scalar_one_or_none()
|
|
|
|
|
if not project:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Project not found.")
|
|
|
|
|
|
|
|
|
|
is_own = current_user and current_user.id == project.user_id
|
feat(metrics): add usage analytics dashboard with country tracking
Track per-user, per-project, and per-board usage to inform pricing tier
decisions. Adds an admin dashboard with KPIs (DAU/WAU/MAU, totals,
success rate), time-series charts for compiles/runs, board family +
FQBN breakdowns, "board diversity" pie chart (key freemium signal),
top users/projects, and per-country breakdown via Cloudflare's
CF-IPCountry header. Admin can now also view private projects.
Backend:
- New UsageEvent table (append-only event log with user_id, project_id,
event_type, board_fqbn/family, country, error_kind, duration_ms)
- Aggregate counters on User (total_compiles/runs/errors, last_active,
signup_country, last_country) and Project (compile/run/update counts,
last_compiled/run timestamps) kept in sync by MetricsService for O(1)
dashboard reads
- 10 admin endpoints under /api/admin/metrics/{overview, timeseries,
boards, board-diversity, top-users, top-projects, countries,
users/{id}, projects/{id}}
- POST /api/metrics/run for client-side run telemetry
- Country detection via cf-ipcountry header (no DB / no API calls)
- Auto-migrations in lifespan for legacy DBs
Frontend:
- recharts-powered Dashboard tab with KPI cards and 4 charts
- New Boards tab with per-family + per-FQBN breakdown
- Country column with flag emoji on Users tab
- Top countries card on Dashboard
- compileCode now forwards project_id; Run button reports via WS
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 05:46:52 +07:00
|
|
|
is_admin = current_user and current_user.is_admin
|
|
|
|
|
if not project.is_public and not is_own and not is_admin:
|
2026-03-07 06:38:06 +07:00
|
|
|
raise HTTPException(status_code=403, detail="This project is private.")
|
|
|
|
|
|
|
|
|
|
owner_result = await db.execute(select(User).where(User.id == project.user_id))
|
|
|
|
|
owner = owner_result.scalar_one_or_none()
|
feat(metrics): add usage analytics dashboard with country tracking
Track per-user, per-project, and per-board usage to inform pricing tier
decisions. Adds an admin dashboard with KPIs (DAU/WAU/MAU, totals,
success rate), time-series charts for compiles/runs, board family +
FQBN breakdowns, "board diversity" pie chart (key freemium signal),
top users/projects, and per-country breakdown via Cloudflare's
CF-IPCountry header. Admin can now also view private projects.
Backend:
- New UsageEvent table (append-only event log with user_id, project_id,
event_type, board_fqbn/family, country, error_kind, duration_ms)
- Aggregate counters on User (total_compiles/runs/errors, last_active,
signup_country, last_country) and Project (compile/run/update counts,
last_compiled/run timestamps) kept in sync by MetricsService for O(1)
dashboard reads
- 10 admin endpoints under /api/admin/metrics/{overview, timeseries,
boards, board-diversity, top-users, top-projects, countries,
users/{id}, projects/{id}}
- POST /api/metrics/run for client-side run telemetry
- Country detection via cf-ipcountry header (no DB / no API calls)
- Auto-migrations in lifespan for legacy DBs
Frontend:
- recharts-powered Dashboard tab with KPI cards and 4 charts
- New Boards tab with per-family + per-FQBN breakdown
- Country column with flag emoji on Users tab
- Top countries card on Dashboard
- compileCode now forwards project_id; Run button reports via WS
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 05:46:52 +07:00
|
|
|
|
|
|
|
|
# Record open events from non-owners (views), not owner edits.
|
|
|
|
|
if not is_own:
|
|
|
|
|
await record_project_open(db, user=current_user, project_id=project.id, request=request)
|
|
|
|
|
|
2026-03-07 06:38:06 +07:00
|
|
|
return _to_response(project, owner.username if owner else "")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Create ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-03-06 20:14:50 +07:00
|
|
|
@router.post("/projects/", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED)
|
|
|
|
|
async def create_project(
|
|
|
|
|
body: ProjectCreateRequest,
|
feat(metrics): add usage analytics dashboard with country tracking
Track per-user, per-project, and per-board usage to inform pricing tier
decisions. Adds an admin dashboard with KPIs (DAU/WAU/MAU, totals,
success rate), time-series charts for compiles/runs, board family +
FQBN breakdowns, "board diversity" pie chart (key freemium signal),
top users/projects, and per-country breakdown via Cloudflare's
CF-IPCountry header. Admin can now also view private projects.
Backend:
- New UsageEvent table (append-only event log with user_id, project_id,
event_type, board_fqbn/family, country, error_kind, duration_ms)
- Aggregate counters on User (total_compiles/runs/errors, last_active,
signup_country, last_country) and Project (compile/run/update counts,
last_compiled/run timestamps) kept in sync by MetricsService for O(1)
dashboard reads
- 10 admin endpoints under /api/admin/metrics/{overview, timeseries,
boards, board-diversity, top-users, top-projects, countries,
users/{id}, projects/{id}}
- POST /api/metrics/run for client-side run telemetry
- Country detection via cf-ipcountry header (no DB / no API calls)
- Auto-migrations in lifespan for legacy DBs
Frontend:
- recharts-powered Dashboard tab with KPI cards and 4 charts
- New Boards tab with per-family + per-FQBN breakdown
- Country column with flag emoji on Users tab
- Top countries card on Dashboard
- compileCode now forwards project_id; Run button reports via WS
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 05:46:52 +07:00
|
|
|
request: Request,
|
2026-03-06 20:14:50 +07:00
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
user: User = Depends(require_auth),
|
|
|
|
|
):
|
|
|
|
|
base_slug = slugify(body.name) or "project"
|
|
|
|
|
slug = await _unique_slug(db, user.id, base_slug)
|
|
|
|
|
|
|
|
|
|
project = Project(
|
|
|
|
|
user_id=user.id,
|
|
|
|
|
name=body.name,
|
|
|
|
|
slug=slug,
|
|
|
|
|
description=body.description,
|
|
|
|
|
is_public=body.is_public,
|
|
|
|
|
board_type=body.board_type,
|
|
|
|
|
code=body.code,
|
|
|
|
|
components_json=body.components_json,
|
|
|
|
|
wires_json=body.wires_json,
|
feat: persist multi-board projects + add auto-save
The project save/load pipeline only persisted a single `board_type`, so
multi-board workspaces silently lost every board except the active one
on save, and wires referencing the dropped boards' IDs orphaned to the
canvas corner on reload. An audit of the production backup found 74/306
projects (24%) with at least one orphaned wire and 174/301 non-trivial
projects whose code was still the default Blink template — strong signal
that users save once and never re-save.
Backend
- Add `boards_json` column on `projects` with idempotent ALTER TABLE in
the lifespan migration list.
- New `FileGroup` schema + `file_groups` array on
ProjectCreate/Update/Response. Legacy `files`/`code` kept for back-compat.
- `project_files.py` now uses `{pid}/{groupId}/{filename}` subdirs via
`read_groups`/`write_groups`. Legacy flat layouts are auto-promoted on
read; legacy single-list `files` only updates the active group, leaving
other boards' files intact.
- `_persist_files_from_body` honors file_groups → files → code priority.
Frontend
- `useSimulatorStore.addBoard` accepts an optional `explicitId` so
saved board IDs can be restored verbatim (wires reference IDs literally).
- New `loadProjectState({boards, fileGroups, components, wires,
activeBoardId})` action: tears down current boards, recreates from the
payload, restores file groups atomically, recalculates wire positions
on the next frame, and refreshes the Interconnect.
- `useEditorStore.replaceFileGroups` for atomic multi-group restore.
- `SaveProjectModal` and `ProjectByIdPage`/`ProjectPage` now go through
`buildSavePayload` / `buildLoadPayload` (handles pre-backfill projects
by synthesising a default board from `board_type`).
Auto-save (#useAutoSaveProject hook)
- 2.5s debounced silent PUT triggered ONLY when an authenticated user
has a `currentProject` with a UUID. State hash detects real changes
vs. UI-only churn; baseline is reset on project load so the just-loaded
state isn't immediately re-saved.
- `beforeunload` flush via `fetch keepalive: true` (supports PUT +
credentials, survives unload).
- Compact status indicator in `AppHeader` (idle/dirty/saving/saved/error).
Backfill script (one-off, idempotent)
- `backend/scripts/backfill_boards_2026_05.py` populates `boards_json`
for legacy projects. Heuristic per project, based on which board IDs
the wires reference:
Case A — wires only ref 'arduino-uno' but board_type ≠ uno:
rename id→board_type and rewrite wire endpoints.
Case B — single-board normal: keep verbatim.
Case C — multi-board: recreate one board per distinct ref, infer
kind by stripping trailing -N suffix.
Also moves any flat files into the active board's group subdir.
Stdlib-only, runs from host or `docker exec`.
Docker
- `Dockerfile.standalone` now copies `backend/scripts/` into the image
so the backfill is callable via `docker exec velxio-app python
/app/scripts/backfill_boards_2026_05.py --apply`.
Verified locally on the restored production backup (363 projects):
33 Case A, 316 Case B, 14 Case C, 135 wire endpoints renamed, 0 orphans.
Re-running the script after apply skips all 363 (idempotent).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:43:33 +07:00
|
|
|
boards_json=body.boards_json or "[]",
|
2026-03-06 20:14:50 +07:00
|
|
|
)
|
|
|
|
|
db.add(project)
|
|
|
|
|
await db.commit()
|
|
|
|
|
await db.refresh(project)
|
2026-03-07 06:38:06 +07:00
|
|
|
|
feat: persist multi-board projects + add auto-save
The project save/load pipeline only persisted a single `board_type`, so
multi-board workspaces silently lost every board except the active one
on save, and wires referencing the dropped boards' IDs orphaned to the
canvas corner on reload. An audit of the production backup found 74/306
projects (24%) with at least one orphaned wire and 174/301 non-trivial
projects whose code was still the default Blink template — strong signal
that users save once and never re-save.
Backend
- Add `boards_json` column on `projects` with idempotent ALTER TABLE in
the lifespan migration list.
- New `FileGroup` schema + `file_groups` array on
ProjectCreate/Update/Response. Legacy `files`/`code` kept for back-compat.
- `project_files.py` now uses `{pid}/{groupId}/{filename}` subdirs via
`read_groups`/`write_groups`. Legacy flat layouts are auto-promoted on
read; legacy single-list `files` only updates the active group, leaving
other boards' files intact.
- `_persist_files_from_body` honors file_groups → files → code priority.
Frontend
- `useSimulatorStore.addBoard` accepts an optional `explicitId` so
saved board IDs can be restored verbatim (wires reference IDs literally).
- New `loadProjectState({boards, fileGroups, components, wires,
activeBoardId})` action: tears down current boards, recreates from the
payload, restores file groups atomically, recalculates wire positions
on the next frame, and refreshes the Interconnect.
- `useEditorStore.replaceFileGroups` for atomic multi-group restore.
- `SaveProjectModal` and `ProjectByIdPage`/`ProjectPage` now go through
`buildSavePayload` / `buildLoadPayload` (handles pre-backfill projects
by synthesising a default board from `board_type`).
Auto-save (#useAutoSaveProject hook)
- 2.5s debounced silent PUT triggered ONLY when an authenticated user
has a `currentProject` with a UUID. State hash detects real changes
vs. UI-only churn; baseline is reset on project load so the just-loaded
state isn't immediately re-saved.
- `beforeunload` flush via `fetch keepalive: true` (supports PUT +
credentials, survives unload).
- Compact status indicator in `AppHeader` (idle/dirty/saving/saved/error).
Backfill script (one-off, idempotent)
- `backend/scripts/backfill_boards_2026_05.py` populates `boards_json`
for legacy projects. Heuristic per project, based on which board IDs
the wires reference:
Case A — wires only ref 'arduino-uno' but board_type ≠ uno:
rename id→board_type and rewrite wire endpoints.
Case B — single-board normal: keep verbatim.
Case C — multi-board: recreate one board per distinct ref, infer
kind by stripping trailing -N suffix.
Also moves any flat files into the active board's group subdir.
Stdlib-only, runs from host or `docker exec`.
Docker
- `Dockerfile.standalone` now copies `backend/scripts/` into the image
so the backfill is callable via `docker exec velxio-app python
/app/scripts/backfill_boards_2026_05.py --apply`.
Verified locally on the restored production backup (363 projects):
33 Case A, 316 Case B, 14 Case C, 135 wire endpoints renamed, 0 orphans.
Re-running the script after apply skips all 363 (idempotent).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:43:33 +07:00
|
|
|
# Write sketch files to volume (multi-group layout under {pid}/{gid}/...)
|
|
|
|
|
_persist_files_from_body(project, body)
|
2026-03-07 06:38:06 +07:00
|
|
|
|
feat(metrics): add usage analytics dashboard with country tracking
Track per-user, per-project, and per-board usage to inform pricing tier
decisions. Adds an admin dashboard with KPIs (DAU/WAU/MAU, totals,
success rate), time-series charts for compiles/runs, board family +
FQBN breakdowns, "board diversity" pie chart (key freemium signal),
top users/projects, and per-country breakdown via Cloudflare's
CF-IPCountry header. Admin can now also view private projects.
Backend:
- New UsageEvent table (append-only event log with user_id, project_id,
event_type, board_fqbn/family, country, error_kind, duration_ms)
- Aggregate counters on User (total_compiles/runs/errors, last_active,
signup_country, last_country) and Project (compile/run/update counts,
last_compiled/run timestamps) kept in sync by MetricsService for O(1)
dashboard reads
- 10 admin endpoints under /api/admin/metrics/{overview, timeseries,
boards, board-diversity, top-users, top-projects, countries,
users/{id}, projects/{id}}
- POST /api/metrics/run for client-side run telemetry
- Country detection via cf-ipcountry header (no DB / no API calls)
- Auto-migrations in lifespan for legacy DBs
Frontend:
- recharts-powered Dashboard tab with KPI cards and 4 charts
- New Boards tab with per-family + per-FQBN breakdown
- Country column with flag emoji on Users tab
- Top countries card on Dashboard
- compileCode now forwards project_id; Run button reports via WS
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 05:46:52 +07:00
|
|
|
await record_save(db, user=user, project=project, is_create=True, request=request)
|
2026-03-06 20:14:50 +07:00
|
|
|
return _to_response(project, user.username)
|
|
|
|
|
|
|
|
|
|
|
2026-03-07 06:38:06 +07:00
|
|
|
# ── Update ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-03-06 20:14:50 +07:00
|
|
|
@router.put("/projects/{project_id}", response_model=ProjectResponse)
|
|
|
|
|
async def update_project(
|
|
|
|
|
project_id: str,
|
|
|
|
|
body: ProjectUpdateRequest,
|
feat(metrics): add usage analytics dashboard with country tracking
Track per-user, per-project, and per-board usage to inform pricing tier
decisions. Adds an admin dashboard with KPIs (DAU/WAU/MAU, totals,
success rate), time-series charts for compiles/runs, board family +
FQBN breakdowns, "board diversity" pie chart (key freemium signal),
top users/projects, and per-country breakdown via Cloudflare's
CF-IPCountry header. Admin can now also view private projects.
Backend:
- New UsageEvent table (append-only event log with user_id, project_id,
event_type, board_fqbn/family, country, error_kind, duration_ms)
- Aggregate counters on User (total_compiles/runs/errors, last_active,
signup_country, last_country) and Project (compile/run/update counts,
last_compiled/run timestamps) kept in sync by MetricsService for O(1)
dashboard reads
- 10 admin endpoints under /api/admin/metrics/{overview, timeseries,
boards, board-diversity, top-users, top-projects, countries,
users/{id}, projects/{id}}
- POST /api/metrics/run for client-side run telemetry
- Country detection via cf-ipcountry header (no DB / no API calls)
- Auto-migrations in lifespan for legacy DBs
Frontend:
- recharts-powered Dashboard tab with KPI cards and 4 charts
- New Boards tab with per-family + per-FQBN breakdown
- Country column with flag emoji on Users tab
- Top countries card on Dashboard
- compileCode now forwards project_id; Run button reports via WS
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 05:46:52 +07:00
|
|
|
request: Request,
|
2026-03-06 20:14:50 +07:00
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
user: User = Depends(require_auth),
|
|
|
|
|
):
|
|
|
|
|
result = await db.execute(select(Project).where(Project.id == project_id))
|
|
|
|
|
project = result.scalar_one_or_none()
|
|
|
|
|
if not project:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Project not found.")
|
|
|
|
|
if project.user_id != user.id:
|
|
|
|
|
raise HTTPException(status_code=403, detail="Forbidden.")
|
|
|
|
|
|
|
|
|
|
if body.name is not None:
|
|
|
|
|
project.name = body.name
|
|
|
|
|
new_base = slugify(body.name)
|
|
|
|
|
if new_base != project.slug:
|
|
|
|
|
project.slug = await _unique_slug(db, user.id, new_base)
|
|
|
|
|
if body.description is not None:
|
|
|
|
|
project.description = body.description
|
|
|
|
|
if body.is_public is not None:
|
|
|
|
|
project.is_public = body.is_public
|
|
|
|
|
if body.board_type is not None:
|
|
|
|
|
project.board_type = body.board_type
|
|
|
|
|
if body.code is not None:
|
|
|
|
|
project.code = body.code
|
|
|
|
|
if body.components_json is not None:
|
|
|
|
|
project.components_json = body.components_json
|
|
|
|
|
if body.wires_json is not None:
|
|
|
|
|
project.wires_json = body.wires_json
|
feat: persist multi-board projects + add auto-save
The project save/load pipeline only persisted a single `board_type`, so
multi-board workspaces silently lost every board except the active one
on save, and wires referencing the dropped boards' IDs orphaned to the
canvas corner on reload. An audit of the production backup found 74/306
projects (24%) with at least one orphaned wire and 174/301 non-trivial
projects whose code was still the default Blink template — strong signal
that users save once and never re-save.
Backend
- Add `boards_json` column on `projects` with idempotent ALTER TABLE in
the lifespan migration list.
- New `FileGroup` schema + `file_groups` array on
ProjectCreate/Update/Response. Legacy `files`/`code` kept for back-compat.
- `project_files.py` now uses `{pid}/{groupId}/{filename}` subdirs via
`read_groups`/`write_groups`. Legacy flat layouts are auto-promoted on
read; legacy single-list `files` only updates the active group, leaving
other boards' files intact.
- `_persist_files_from_body` honors file_groups → files → code priority.
Frontend
- `useSimulatorStore.addBoard` accepts an optional `explicitId` so
saved board IDs can be restored verbatim (wires reference IDs literally).
- New `loadProjectState({boards, fileGroups, components, wires,
activeBoardId})` action: tears down current boards, recreates from the
payload, restores file groups atomically, recalculates wire positions
on the next frame, and refreshes the Interconnect.
- `useEditorStore.replaceFileGroups` for atomic multi-group restore.
- `SaveProjectModal` and `ProjectByIdPage`/`ProjectPage` now go through
`buildSavePayload` / `buildLoadPayload` (handles pre-backfill projects
by synthesising a default board from `board_type`).
Auto-save (#useAutoSaveProject hook)
- 2.5s debounced silent PUT triggered ONLY when an authenticated user
has a `currentProject` with a UUID. State hash detects real changes
vs. UI-only churn; baseline is reset on project load so the just-loaded
state isn't immediately re-saved.
- `beforeunload` flush via `fetch keepalive: true` (supports PUT +
credentials, survives unload).
- Compact status indicator in `AppHeader` (idle/dirty/saving/saved/error).
Backfill script (one-off, idempotent)
- `backend/scripts/backfill_boards_2026_05.py` populates `boards_json`
for legacy projects. Heuristic per project, based on which board IDs
the wires reference:
Case A — wires only ref 'arduino-uno' but board_type ≠ uno:
rename id→board_type and rewrite wire endpoints.
Case B — single-board normal: keep verbatim.
Case C — multi-board: recreate one board per distinct ref, infer
kind by stripping trailing -N suffix.
Also moves any flat files into the active board's group subdir.
Stdlib-only, runs from host or `docker exec`.
Docker
- `Dockerfile.standalone` now copies `backend/scripts/` into the image
so the backfill is callable via `docker exec velxio-app python
/app/scripts/backfill_boards_2026_05.py --apply`.
Verified locally on the restored production backup (363 projects):
33 Case A, 316 Case B, 14 Case C, 135 wire endpoints renamed, 0 orphans.
Re-running the script after apply skips all 363 (idempotent).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:43:33 +07:00
|
|
|
if body.boards_json is not None:
|
|
|
|
|
project.boards_json = body.boards_json
|
2026-03-06 20:14:50 +07:00
|
|
|
|
|
|
|
|
project.updated_at = datetime.now(timezone.utc)
|
|
|
|
|
await db.commit()
|
|
|
|
|
await db.refresh(project)
|
2026-03-07 06:38:06 +07:00
|
|
|
|
feat: persist multi-board projects + add auto-save
The project save/load pipeline only persisted a single `board_type`, so
multi-board workspaces silently lost every board except the active one
on save, and wires referencing the dropped boards' IDs orphaned to the
canvas corner on reload. An audit of the production backup found 74/306
projects (24%) with at least one orphaned wire and 174/301 non-trivial
projects whose code was still the default Blink template — strong signal
that users save once and never re-save.
Backend
- Add `boards_json` column on `projects` with idempotent ALTER TABLE in
the lifespan migration list.
- New `FileGroup` schema + `file_groups` array on
ProjectCreate/Update/Response. Legacy `files`/`code` kept for back-compat.
- `project_files.py` now uses `{pid}/{groupId}/{filename}` subdirs via
`read_groups`/`write_groups`. Legacy flat layouts are auto-promoted on
read; legacy single-list `files` only updates the active group, leaving
other boards' files intact.
- `_persist_files_from_body` honors file_groups → files → code priority.
Frontend
- `useSimulatorStore.addBoard` accepts an optional `explicitId` so
saved board IDs can be restored verbatim (wires reference IDs literally).
- New `loadProjectState({boards, fileGroups, components, wires,
activeBoardId})` action: tears down current boards, recreates from the
payload, restores file groups atomically, recalculates wire positions
on the next frame, and refreshes the Interconnect.
- `useEditorStore.replaceFileGroups` for atomic multi-group restore.
- `SaveProjectModal` and `ProjectByIdPage`/`ProjectPage` now go through
`buildSavePayload` / `buildLoadPayload` (handles pre-backfill projects
by synthesising a default board from `board_type`).
Auto-save (#useAutoSaveProject hook)
- 2.5s debounced silent PUT triggered ONLY when an authenticated user
has a `currentProject` with a UUID. State hash detects real changes
vs. UI-only churn; baseline is reset on project load so the just-loaded
state isn't immediately re-saved.
- `beforeunload` flush via `fetch keepalive: true` (supports PUT +
credentials, survives unload).
- Compact status indicator in `AppHeader` (idle/dirty/saving/saved/error).
Backfill script (one-off, idempotent)
- `backend/scripts/backfill_boards_2026_05.py` populates `boards_json`
for legacy projects. Heuristic per project, based on which board IDs
the wires reference:
Case A — wires only ref 'arduino-uno' but board_type ≠ uno:
rename id→board_type and rewrite wire endpoints.
Case B — single-board normal: keep verbatim.
Case C — multi-board: recreate one board per distinct ref, infer
kind by stripping trailing -N suffix.
Also moves any flat files into the active board's group subdir.
Stdlib-only, runs from host or `docker exec`.
Docker
- `Dockerfile.standalone` now copies `backend/scripts/` into the image
so the backfill is callable via `docker exec velxio-app python
/app/scripts/backfill_boards_2026_05.py --apply`.
Verified locally on the restored production backup (363 projects):
33 Case A, 316 Case B, 14 Case C, 135 wire endpoints renamed, 0 orphans.
Re-running the script after apply skips all 363 (idempotent).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:43:33 +07:00
|
|
|
# Write updated files to volume (multi-group; legacy `files` only updates
|
|
|
|
|
# the active group, leaving other boards intact).
|
|
|
|
|
_persist_files_from_body(project, body)
|
2026-03-07 06:38:06 +07:00
|
|
|
|
feat(metrics): add usage analytics dashboard with country tracking
Track per-user, per-project, and per-board usage to inform pricing tier
decisions. Adds an admin dashboard with KPIs (DAU/WAU/MAU, totals,
success rate), time-series charts for compiles/runs, board family +
FQBN breakdowns, "board diversity" pie chart (key freemium signal),
top users/projects, and per-country breakdown via Cloudflare's
CF-IPCountry header. Admin can now also view private projects.
Backend:
- New UsageEvent table (append-only event log with user_id, project_id,
event_type, board_fqbn/family, country, error_kind, duration_ms)
- Aggregate counters on User (total_compiles/runs/errors, last_active,
signup_country, last_country) and Project (compile/run/update counts,
last_compiled/run timestamps) kept in sync by MetricsService for O(1)
dashboard reads
- 10 admin endpoints under /api/admin/metrics/{overview, timeseries,
boards, board-diversity, top-users, top-projects, countries,
users/{id}, projects/{id}}
- POST /api/metrics/run for client-side run telemetry
- Country detection via cf-ipcountry header (no DB / no API calls)
- Auto-migrations in lifespan for legacy DBs
Frontend:
- recharts-powered Dashboard tab with KPI cards and 4 charts
- New Boards tab with per-family + per-FQBN breakdown
- Country column with flag emoji on Users tab
- Top countries card on Dashboard
- compileCode now forwards project_id; Run button reports via WS
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 05:46:52 +07:00
|
|
|
await record_save(db, user=user, project=project, is_create=False, request=request)
|
2026-03-06 20:14:50 +07:00
|
|
|
return _to_response(project, user.username)
|
|
|
|
|
|
|
|
|
|
|
2026-03-07 06:38:06 +07:00
|
|
|
# ── Delete ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-03-06 20:14:50 +07:00
|
|
|
@router.delete("/projects/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
|
|
|
async def delete_project(
|
|
|
|
|
project_id: str,
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
user: User = Depends(require_auth),
|
|
|
|
|
):
|
|
|
|
|
result = await db.execute(select(Project).where(Project.id == project_id))
|
|
|
|
|
project = result.scalar_one_or_none()
|
|
|
|
|
if not project:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Project not found.")
|
|
|
|
|
if project.user_id != user.id:
|
|
|
|
|
raise HTTPException(status_code=403, detail="Forbidden.")
|
|
|
|
|
await db.delete(project)
|
|
|
|
|
await db.commit()
|
2026-03-07 06:38:06 +07:00
|
|
|
delete_files(project_id)
|
2026-03-06 20:14:50 +07:00
|
|
|
|
|
|
|
|
|
2026-03-07 06:38:06 +07:00
|
|
|
# ── User public projects ─────────────────────────────────────────────────────
|
|
|
|
|
|
2026-03-06 20:14:50 +07:00
|
|
|
@router.get("/user/{username}", response_model=list[ProjectResponse])
|
|
|
|
|
async def user_projects(
|
|
|
|
|
username: str,
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
current_user: User | None = Depends(get_current_user),
|
|
|
|
|
):
|
|
|
|
|
result = await db.execute(select(User).where(User.username == username))
|
|
|
|
|
owner = result.scalar_one_or_none()
|
|
|
|
|
if not owner:
|
|
|
|
|
raise HTTPException(status_code=404, detail="User not found.")
|
|
|
|
|
|
|
|
|
|
is_own = current_user and current_user.id == owner.id
|
|
|
|
|
query = select(Project).where(Project.user_id == owner.id)
|
|
|
|
|
if not is_own:
|
|
|
|
|
query = query.where(Project.is_public == True) # noqa: E712
|
|
|
|
|
query = query.order_by(Project.updated_at.desc())
|
|
|
|
|
|
|
|
|
|
projects = (await db.execute(query)).scalars().all()
|
|
|
|
|
return [_to_response(p, owner.username) for p in projects]
|
|
|
|
|
|
|
|
|
|
|
2026-03-07 06:38:06 +07:00
|
|
|
# ── Get by username/slug ─────────────────────────────────────────────────────
|
|
|
|
|
|
2026-03-06 20:14:50 +07:00
|
|
|
@router.get("/user/{username}/{slug}", response_model=ProjectResponse)
|
2026-03-07 06:38:06 +07:00
|
|
|
async def get_project_by_slug(
|
2026-03-06 20:14:50 +07:00
|
|
|
username: str,
|
|
|
|
|
slug: str,
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
current_user: User | None = Depends(get_current_user),
|
|
|
|
|
):
|
|
|
|
|
result = await db.execute(select(User).where(User.username == username))
|
|
|
|
|
owner = result.scalar_one_or_none()
|
|
|
|
|
if not owner:
|
|
|
|
|
raise HTTPException(status_code=404, detail="User not found.")
|
|
|
|
|
|
|
|
|
|
result2 = await db.execute(
|
|
|
|
|
select(Project).where(Project.user_id == owner.id, Project.slug == slug)
|
|
|
|
|
)
|
|
|
|
|
project = result2.scalar_one_or_none()
|
|
|
|
|
if not project:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Project not found.")
|
|
|
|
|
|
|
|
|
|
is_own = current_user and current_user.id == owner.id
|
|
|
|
|
if not project.is_public and not is_own:
|
2026-03-07 06:38:06 +07:00
|
|
|
raise HTTPException(status_code=403, detail="This project is private.")
|
2026-03-06 20:14:50 +07:00
|
|
|
|
|
|
|
|
return _to_response(project, owner.username)
|