Skip to main content

max / makenotwork

Expand tag taxonomy, add OTA slug UI to SyncKit dashboard Migration 056: adds 18 new tags under Software, Audio, Topics, and a new Technology category — covers all tags needed for app content seeding (productivity, tasks, email, samples, daw, rust, etc.). Dashboard: OTA slug column in SyncKit apps table with inline edit. Session-authenticated PUT /api/sync/apps/{id}/slug endpoint so slugs can be set from the dashboard without JWT auth. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-10 16:48 UTC
Commit: 19f67b5e70e593782df3ca2b6e193469f86c08da
Parent: 8d9ee7c
7 files changed, +132 insertions, -0 deletions
@@ -0,0 +1,34 @@
1 + -- Expand tag taxonomy for app content seeding.
2 + -- Adds platform, tool-specific, and format tags under existing categories.
3 + -- path = materialized dot-notation (parent_slug.child_slug).
4 +
5 + -- Software children (new)
6 + INSERT INTO tags (name, slug, parent_id, sort_order, path) VALUES
7 + ('Desktop', 'desktop', (SELECT id FROM tags WHERE slug = 'software'), 3, 'software.desktop'),
8 + ('macOS', 'macos', (SELECT id FROM tags WHERE slug = 'software'), 4, 'software.macos'),
9 + ('DAW', 'daw', (SELECT id FROM tags WHERE slug = 'software'), 5, 'software.daw'),
10 + ('CLAP', 'clap', (SELECT id FROM tags WHERE slug = 'software'), 6, 'software.clap'),
11 + ('VST3', 'vst3', (SELECT id FROM tags WHERE slug = 'software'), 7, 'software.vst3');
12 +
13 + -- Audio children (new)
14 + INSERT INTO tags (name, slug, parent_id, sort_order, path) VALUES
15 + ('Samples', 'samples', (SELECT id FROM tags WHERE slug = 'audio'), 3, 'audio.samples'),
16 + ('Music Production', 'music-production', (SELECT id FROM tags WHERE slug = 'audio'), 4, 'audio.music-production');
17 +
18 + -- Topics children (new)
19 + INSERT INTO tags (name, slug, parent_id, sort_order, path) VALUES
20 + ('Tasks', 'tasks', (SELECT id FROM tags WHERE slug = 'topics'), 3, 'topics.tasks'),
21 + ('Email', 'email', (SELECT id FROM tags WHERE slug = 'topics'), 4, 'topics.email'),
22 + ('Calendar', 'calendar', (SELECT id FROM tags WHERE slug = 'topics'), 5, 'topics.calendar'),
23 + ('RSS', 'rss', (SELECT id FROM tags WHERE slug = 'topics'), 6, 'topics.rss'),
24 + ('Feeds', 'feeds', (SELECT id FROM tags WHERE slug = 'topics'), 7, 'topics.feeds'),
25 + ('Reader', 'reader', (SELECT id FROM tags WHERE slug = 'topics'), 8, 'topics.reader'),
26 + ('Platform', 'platform', (SELECT id FROM tags WHERE slug = 'topics'), 9, 'topics.platform'),
27 + ('Development', 'development', (SELECT id FROM tags WHERE slug = 'topics'), 10, 'topics.development');
28 +
29 + -- New top-level category: Technology
30 + INSERT INTO tags (name, slug, parent_id, sort_order, path) VALUES
31 + ('Technology', 'technology', NULL, 4, 'technology');
32 +
33 + INSERT INTO tags (name, slug, parent_id, sort_order, path) VALUES
34 + ('Rust', 'rust', (SELECT id FROM tags WHERE slug = 'technology'), 0, 'technology.rust');
@@ -31,6 +31,8 @@ const ALLOWED_ARCHS: &[&str] = &["x86_64", "aarch64"];
31 31
32 32 /// Validate an app slug: 3-40 chars, lowercase alphanumeric + hyphens,
33 33 /// no leading/trailing hyphens.
34 + ///
35 + /// Also exposed as `validate_slug_public` for the session-auth slug endpoint.
34 36 fn validate_slug(slug: &str) -> Result<()> {
35 37 if slug.len() < 3 || slug.len() > 40 {
36 38 return Err(AppError::BadRequest(
@@ -457,3 +459,8 @@ pub fn ota_routes() -> Router<AppState> {
457 459
458 460 mgmt_routes.merge(public_routes)
459 461 }
462 +
463 + /// Public slug validation for use by session-auth endpoints.
464 + pub fn validate_slug_public(slug: &str) -> Result<()> {
465 + validate_slug(slug)
466 + }
@@ -584,6 +584,7 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_tab_synckit(
584 584 device_count,
585 585 log_entry_count,
586 586 created_at: app.created_at.format("%b %d, %Y").to_string(),
587 + slug: app.slug.clone(),
587 588 project_name,
588 589 project_slug,
589 590 item_title,
@@ -14,6 +14,8 @@ use crate::{
14 14 AppState,
15 15 };
16 16
17 + use super::UpdateAppSlugRequest;
18 +
17 19 use super::{CreateAppRequest, UpdateAppLinkRequest};
18 20
19 21 /// Create a new sync app and generate its API key.
@@ -123,6 +125,32 @@ pub(super) async fn update_app_link(
123 125 Ok(Json(updated))
124 126 }
125 127
128 + /// Set the OTA slug for a sync app.
129 + ///
130 + /// `PUT /api/sync/apps/{id}/slug` -- Session auth required.
131 + #[tracing::instrument(skip_all, name = "synckit::update_app_slug")]
132 + pub(super) async fn update_app_slug(
133 + State(state): State<AppState>,
134 + AuthUser(user): AuthUser,
135 + Path(app_id): Path<SyncAppId>,
136 + Json(req): Json<UpdateAppSlugRequest>,
137 + ) -> Result<impl IntoResponse> {
138 + let app = db::synckit::get_sync_app_by_id(&state.db, app_id)
139 + .await?
140 + .ok_or(AppError::NotFound)?;
141 +
142 + if app.creator_id != user.id {
143 + return Err(AppError::Forbidden);
144 + }
145 +
146 + // Reuse the OTA slug validation
147 + crate::routes::ota::validate_slug_public(&req.slug)?;
148 +
149 + db::ota::set_app_slug(&state.db, app_id, &req.slug).await?;
150 +
151 + Ok(axum::http::StatusCode::NO_CONTENT)
152 + }
153 +
126 154 // ── Link helpers ──
127 155
128 156 /// Parse an optional UUID string and verify the project belongs to the user.
@@ -127,6 +127,11 @@ pub struct UpdateAppLinkRequest {
127 127 pub item_id: Option<String>,
128 128 }
129 129
130 + #[derive(Deserialize)]
131 + pub struct UpdateAppSlugRequest {
132 + pub slug: String,
133 + }
134 +
130 135 #[derive(Serialize)]
131 136 pub(super) struct SyncStatusResponse {
132 137 total_changes: i64,
@@ -228,6 +233,7 @@ pub fn synckit_routes() -> Router<AppState> {
228 233 .route("/api/sync/apps", get(apps::list_apps))
229 234 .route("/api/sync/apps/{id}/regenerate-key", post(apps::regenerate_app_key))
230 235 .route("/api/sync/apps/{id}/link", put(apps::update_app_link))
236 + .route("/api/sync/apps/{id}/slug", put(apps::update_app_slug))
231 237 .route("/api/sync/apps/{id}", delete(apps::delete_app));
232 238
233 239 auth_routes.merge(sync_routes).merge(app_routes)
@@ -414,6 +414,7 @@ pub struct SyncAppRow {
414 414 pub device_count: i64,
415 415 pub log_entry_count: i64,
416 416 pub created_at: String,
417 + pub slug: Option<String>,
417 418 pub project_name: Option<String>,
418 419 pub project_slug: Option<String>,
419 420 pub item_title: Option<String>,
@@ -40,6 +40,7 @@
40 40 <thead>
41 41 <tr>
42 42 <th>Name</th>
43 + <th>OTA Slug</th>
43 44 <th>Linked To</th>
44 45 <th>API Key</th>
45 46 <th>Status</th>
@@ -53,6 +54,16 @@
53 54 {% for app in apps %}
54 55 <tr>
55 56 <td>{{ app.name }}</td>
57 + <td id="synckit-slug-cell-{{ app.id }}">
58 + {% if let Some(s) = app.slug %}
59 + <code style="font-size: 0.8rem;">{{ s }}</code>
60 + {% else %}
61 + <span class="dimmed">&mdash;</span>
62 + {% endif %}
63 + <button class="btn-small secondary"
64 + onclick="syncKitShowSlugForm('{{ app.id }}', '{{ app.slug.as_deref().unwrap_or_default() }}')"
65 + style="margin-left: 0.5rem; padding: 0.15rem 0.4rem; font-size: 0.75rem;">Set</button>
66 + </td>
56 67 <td id="synckit-link-cell-{{ app.id }}">
57 68 {% if let Some(pname) = app.project_name %}
58 69 {% if let Some(pslug) = app.project_slug %}
@@ -143,6 +154,50 @@ function syncKitDeleteApp(appId, name) {
143 154 });
144 155 }
145 156
157 + function syncKitShowSlugForm(appId, currentSlug) {
158 + var cell = document.getElementById('synckit-slug-cell-' + appId);
159 + if (!cell) return;
160 + cell.innerHTML = '';
161 + var input = document.createElement('input');
162 + input.type = 'text';
163 + input.value = currentSlug;
164 + input.placeholder = 'e.g. goingson';
165 + input.style.cssText = 'padding: 0.3rem; font-size: 0.85rem; width: 120px;';
166 + cell.appendChild(input);
167 + cell.appendChild(document.createTextNode(' '));
168 + var saveBtn = document.createElement('button');
169 + saveBtn.className = 'btn-small';
170 + saveBtn.style.cssText = 'padding: 0.15rem 0.4rem; font-size: 0.75rem;';
171 + saveBtn.textContent = 'Save';
172 + saveBtn.addEventListener('click', function() {
173 + var slug = input.value.trim();
174 + if (!slug) return;
175 + fetch('/api/sync/apps/' + appId + '/slug', {
176 + method: 'PUT',
177 + credentials: 'same-origin',
178 + headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
179 + body: JSON.stringify({ slug: slug })
180 + }).then(function(res) {
181 + if (res.ok) {
182 + document.getElementById('tab-synckit').click();
183 + showToast('OTA slug set to "' + slug + '".');
184 + } else {
185 + res.text().then(function(t) { showToast(t || 'Failed to set slug.'); });
186 + }
187 + }).catch(function() {
188 + showToast('Network error. Please check your connection and try again.');
189 + });
190 + });
191 + cell.appendChild(saveBtn);
192 + cell.appendChild(document.createTextNode(' '));
193 + var cancelBtn = document.createElement('button');
194 + cancelBtn.className = 'btn-small secondary';
195 + cancelBtn.style.cssText = 'padding: 0.15rem 0.4rem; font-size: 0.75rem;';
196 + cancelBtn.textContent = 'Cancel';
197 + cancelBtn.addEventListener('click', function() { document.getElementById('tab-synckit').click(); });
198 + cell.appendChild(cancelBtn);
199 + }
200 +
146 201 function syncKitShowLinkForm(appId) {
147 202 var cell = document.getElementById('synckit-link-cell-' + appId);
148 203 if (!cell) return;