2025-10-28 13:21:51 +01:00
|
|
|
import { checkAchievements } from "./gamification";
|
|
|
|
|
|
2025-10-25 22:04:41 +02:00
|
|
|
const createPolicyFilter = (policy) => ({
|
|
|
|
|
_or: [
|
|
|
|
|
{
|
|
|
|
|
policies: {
|
|
|
|
|
policy: {
|
|
|
|
|
name: {
|
|
|
|
|
_eq: policy,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
role: {
|
|
|
|
|
name: {
|
|
|
|
|
_eq: policy,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export default {
|
|
|
|
|
id: "sexy",
|
|
|
|
|
handler: (router, context) => {
|
2025-10-28 11:08:53 +01:00
|
|
|
const { services, getSchema, database } = context;
|
2025-10-25 22:04:41 +02:00
|
|
|
const { ItemsService } = services;
|
|
|
|
|
|
|
|
|
|
router.get("/stats", async (_req, res) => {
|
|
|
|
|
const usersService = new ItemsService("directus_users", {
|
|
|
|
|
schema: await getSchema(),
|
|
|
|
|
});
|
|
|
|
|
const modelsCount = await usersService.readByQuery({
|
|
|
|
|
aggregate: {
|
|
|
|
|
count: ["*"],
|
|
|
|
|
},
|
|
|
|
|
filter: createPolicyFilter("Model"),
|
|
|
|
|
});
|
|
|
|
|
const viewersCount = await usersService.readByQuery({
|
|
|
|
|
aggregate: {
|
|
|
|
|
count: ["*"],
|
|
|
|
|
},
|
|
|
|
|
filter: createPolicyFilter("Viewer"),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const videosService = new ItemsService("sexy_videos", {
|
|
|
|
|
schema: await getSchema(),
|
|
|
|
|
});
|
|
|
|
|
const videosCount = await videosService.readByQuery({
|
|
|
|
|
aggregate: {
|
|
|
|
|
count: ["*"],
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
res.json({
|
|
|
|
|
models_count: modelsCount[0].count,
|
|
|
|
|
viewers_count: viewersCount[0].count,
|
|
|
|
|
videos_count: videosCount[0].count,
|
|
|
|
|
});
|
|
|
|
|
});
|
feat: add buttplug device recording feature (Phase 1 & 2)
Implemented complete infrastructure for recording, saving, and managing
buttplug device patterns with precise event timing.
**Phase 1: Backend & Infrastructure**
- Added Directus schema for sexy_recordings collection with all fields
(id, status, user_created, title, description, slug, duration, events,
device_info, tags, linked_video, featured, public)
- Created REST API endpoints in bundle extension:
* GET /sexy/recordings - list user recordings with filtering
* GET /sexy/recordings/:id - get single recording
* POST /sexy/recordings - create new recording with validation
* PATCH /sexy/recordings/:id - update recording (owner only)
* DELETE /sexy/recordings/:id - soft delete by archiving
- Added TypeScript types: RecordedEvent, DeviceInfo, Recording
- Created frontend services: getRecordings(), deleteRecording()
- Built RecordingCard component with stats, device info, and actions
- Added Recordings tab to /me dashboard page with grid layout
- Added i18n translations for recordings UI
**Phase 2: Recording Capture**
- Implemented recording state management in /play page
- Added Start/Stop Recording buttons with visual indicators
- Capture device events with precise timestamps during recording
- Normalize actuator values (0-100) for cross-device compatibility
- Created RecordingSaveDialog component with:
* Recording stats display (duration, events, devices)
* Form inputs (title, description, tags)
* Device information preview
- Integrated save recording API call from play page
- Added success/error toast notifications
- Automatic event filtering during recording
**Technical Details**
- Events stored as JSON array with timestamp, deviceIndex, deviceName,
actuatorIndex, actuatorType, and normalized value
- Device metadata includes name, index, and capability list
- Slug auto-generated from title for SEO-friendly URLs
- Status workflow: draft → published → archived
- Permission checks: users can only access own recordings or public ones
- Frontend uses performance.now() for millisecond precision timing
**User Flow**
1. User scans and connects devices on /play page
2. Clicks "Start Recording" to begin capturing events
3. Manipulates device sliders - all changes are recorded
4. Clicks "Stop Recording" to end capture
5. Save dialog appears with recording preview and form
6. User enters title, description, tags and saves
7. Recording appears in dashboard /me Recordings tab
8. Can play back, edit, or delete recordings
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-28 04:05:09 +01:00
|
|
|
|
2025-10-28 11:04:34 +01:00
|
|
|
// GET /sexy/models - Public endpoint to fetch models (bypasses permissions)
|
|
|
|
|
router.get("/models", async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const { featured, limit } = req.query;
|
|
|
|
|
|
2025-10-28 11:08:53 +01:00
|
|
|
// Build query using Knex to bypass permissions
|
|
|
|
|
let query = database
|
|
|
|
|
.select("u.*")
|
|
|
|
|
.from("directus_users as u")
|
|
|
|
|
.leftJoin("directus_roles as r", "u.role", "r.id")
|
|
|
|
|
.where("r.name", "Model")
|
|
|
|
|
.orderBy("u.id", "desc");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (limit) {
|
|
|
|
|
query = query.limit(parseInt(limit as string));
|
2025-10-28 11:04:34 +01:00
|
|
|
}
|
|
|
|
|
|
2025-10-28 11:08:53 +01:00
|
|
|
const models = await query;
|
|
|
|
|
|
|
|
|
|
// Fetch related photos and banner for each model
|
|
|
|
|
for (const model of models) {
|
|
|
|
|
// Fetch photos
|
|
|
|
|
const photos = await database
|
|
|
|
|
.select("df.*")
|
|
|
|
|
.from("sexy_model_photos as mp")
|
|
|
|
|
.leftJoin("directus_files as df", "mp.directus_files_id", "df.id")
|
|
|
|
|
.where("mp.directus_users_id", model.id);
|
|
|
|
|
|
|
|
|
|
model.photos = photos.map((p) => ({ directus_files_id: p }));
|
|
|
|
|
|
|
|
|
|
// Fetch banner
|
|
|
|
|
if (model.banner) {
|
|
|
|
|
const banner = await database
|
|
|
|
|
.select("*")
|
|
|
|
|
.from("directus_files")
|
|
|
|
|
.where("id", model.banner)
|
|
|
|
|
.first();
|
|
|
|
|
model.banner = banner;
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-10-28 11:04:34 +01:00
|
|
|
|
|
|
|
|
res.json(models);
|
|
|
|
|
} catch (error: any) {
|
2025-10-28 11:08:53 +01:00
|
|
|
console.error("Models endpoint error:", error);
|
2025-10-28 11:04:34 +01:00
|
|
|
res.status(500).json({ error: error.message || "Failed to fetch models" });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2025-10-28 11:17:51 +01:00
|
|
|
// GET /sexy/models/:slug - Get single model by slug
|
|
|
|
|
router.get("/models/:slug", async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const { slug } = req.params;
|
|
|
|
|
|
|
|
|
|
const model = await database
|
|
|
|
|
.select("u.*")
|
|
|
|
|
.from("directus_users as u")
|
|
|
|
|
.leftJoin("directus_roles as r", "u.role", "r.id")
|
|
|
|
|
.where("r.name", "Model")
|
|
|
|
|
.where(database.raw("LOWER(u.first_name || ' ' || u.last_name)"), slug.toLowerCase().replace(/-/g, " "))
|
|
|
|
|
.first();
|
|
|
|
|
|
|
|
|
|
if (!model) {
|
|
|
|
|
return res.status(404).json({ error: "Model not found" });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fetch photos
|
|
|
|
|
const photos = await database
|
|
|
|
|
.select("df.*")
|
|
|
|
|
.from("sexy_model_photos as mp")
|
|
|
|
|
.leftJoin("directus_files as df", "mp.directus_files_id", "df.id")
|
|
|
|
|
.where("mp.directus_users_id", model.id);
|
|
|
|
|
|
|
|
|
|
model.photos = photos.map((p) => ({ directus_files_id: p }));
|
|
|
|
|
|
|
|
|
|
// Fetch banner
|
|
|
|
|
if (model.banner) {
|
|
|
|
|
const banner = await database
|
|
|
|
|
.select("*")
|
|
|
|
|
.from("directus_files")
|
|
|
|
|
.where("id", model.banner)
|
|
|
|
|
.first();
|
|
|
|
|
model.banner = banner;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
res.json(model);
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
console.error("Model by slug error:", error);
|
|
|
|
|
res.status(500).json({ error: error.message || "Failed to fetch model" });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// GET /sexy/videos - List videos
|
|
|
|
|
router.get("/videos", async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const { model_id, limit } = req.query;
|
|
|
|
|
|
|
|
|
|
let query = database
|
|
|
|
|
.select("v.*")
|
|
|
|
|
.from("sexy_videos as v")
|
|
|
|
|
.where("v.upload_date", "<=", new Date().toISOString())
|
|
|
|
|
.orderBy("v.upload_date", "desc");
|
|
|
|
|
|
|
|
|
|
if (model_id) {
|
|
|
|
|
query = query
|
|
|
|
|
.leftJoin("sexy_videos_models as vm", "v.id", "vm.sexy_videos_id")
|
|
|
|
|
.where("vm.directus_users_id", model_id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (limit) {
|
|
|
|
|
query = query.limit(parseInt(limit as string));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const videos = await query;
|
|
|
|
|
|
|
|
|
|
// Fetch models and movie for each video
|
|
|
|
|
for (const video of videos) {
|
|
|
|
|
// Fetch models
|
|
|
|
|
const models = await database
|
|
|
|
|
.select("u.*")
|
|
|
|
|
.from("sexy_videos_models as vm")
|
|
|
|
|
.leftJoin("directus_users as u", "vm.directus_users_id", "u.id")
|
|
|
|
|
.where("vm.sexy_videos_id", video.id);
|
|
|
|
|
|
|
|
|
|
video.models = models;
|
|
|
|
|
|
|
|
|
|
// Fetch movie file
|
|
|
|
|
if (video.movie) {
|
|
|
|
|
const movie = await database
|
|
|
|
|
.select("*")
|
|
|
|
|
.from("directus_files")
|
|
|
|
|
.where("id", video.movie)
|
|
|
|
|
.first();
|
|
|
|
|
video.movie = movie;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
res.json(videos);
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
console.error("Videos endpoint error:", error);
|
|
|
|
|
res.status(500).json({ error: error.message || "Failed to fetch videos" });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// GET /sexy/articles - List articles
|
|
|
|
|
router.get("/articles", async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const { featured, limit } = req.query;
|
|
|
|
|
|
|
|
|
|
let query = database
|
|
|
|
|
.select("a.*")
|
|
|
|
|
.from("sexy_articles as a")
|
|
|
|
|
.where("a.publish_date", "<=", new Date().toISOString())
|
|
|
|
|
.orderBy("a.publish_date", "desc");
|
|
|
|
|
|
|
|
|
|
query = query.where("a.featured", true);
|
|
|
|
|
|
|
|
|
|
if (limit) {
|
|
|
|
|
query = query.limit(parseInt(limit as string));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const articles = await query;
|
|
|
|
|
|
|
|
|
|
// Fetch author for each article
|
|
|
|
|
for (const article of articles) {
|
|
|
|
|
if (article.author) {
|
|
|
|
|
const author = await database
|
|
|
|
|
.select("*")
|
|
|
|
|
.from("directus_users")
|
|
|
|
|
.where("id", article.author)
|
|
|
|
|
.first();
|
|
|
|
|
article.author = author;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
res.json(articles);
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
console.error("Articles endpoint error:", error);
|
|
|
|
|
res.status(500).json({ error: error.message || "Failed to fetch articles" });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
feat: add buttplug device recording feature (Phase 1 & 2)
Implemented complete infrastructure for recording, saving, and managing
buttplug device patterns with precise event timing.
**Phase 1: Backend & Infrastructure**
- Added Directus schema for sexy_recordings collection with all fields
(id, status, user_created, title, description, slug, duration, events,
device_info, tags, linked_video, featured, public)
- Created REST API endpoints in bundle extension:
* GET /sexy/recordings - list user recordings with filtering
* GET /sexy/recordings/:id - get single recording
* POST /sexy/recordings - create new recording with validation
* PATCH /sexy/recordings/:id - update recording (owner only)
* DELETE /sexy/recordings/:id - soft delete by archiving
- Added TypeScript types: RecordedEvent, DeviceInfo, Recording
- Created frontend services: getRecordings(), deleteRecording()
- Built RecordingCard component with stats, device info, and actions
- Added Recordings tab to /me dashboard page with grid layout
- Added i18n translations for recordings UI
**Phase 2: Recording Capture**
- Implemented recording state management in /play page
- Added Start/Stop Recording buttons with visual indicators
- Capture device events with precise timestamps during recording
- Normalize actuator values (0-100) for cross-device compatibility
- Created RecordingSaveDialog component with:
* Recording stats display (duration, events, devices)
* Form inputs (title, description, tags)
* Device information preview
- Integrated save recording API call from play page
- Added success/error toast notifications
- Automatic event filtering during recording
**Technical Details**
- Events stored as JSON array with timestamp, deviceIndex, deviceName,
actuatorIndex, actuatorType, and normalized value
- Device metadata includes name, index, and capability list
- Slug auto-generated from title for SEO-friendly URLs
- Status workflow: draft → published → archived
- Permission checks: users can only access own recordings or public ones
- Frontend uses performance.now() for millisecond precision timing
**User Flow**
1. User scans and connects devices on /play page
2. Clicks "Start Recording" to begin capturing events
3. Manipulates device sliders - all changes are recorded
4. Clicks "Stop Recording" to end capture
5. Save dialog appears with recording preview and form
6. User enters title, description, tags and saves
7. Recording appears in dashboard /me Recordings tab
8. Can play back, edit, or delete recordings
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-28 04:05:09 +01:00
|
|
|
// GET /sexy/recordings - List user's recordings
|
|
|
|
|
router.get("/recordings", async (req, res) => {
|
2025-10-28 05:16:36 +01:00
|
|
|
const accountability = req.accountability;
|
feat: add buttplug device recording feature (Phase 1 & 2)
Implemented complete infrastructure for recording, saving, and managing
buttplug device patterns with precise event timing.
**Phase 1: Backend & Infrastructure**
- Added Directus schema for sexy_recordings collection with all fields
(id, status, user_created, title, description, slug, duration, events,
device_info, tags, linked_video, featured, public)
- Created REST API endpoints in bundle extension:
* GET /sexy/recordings - list user recordings with filtering
* GET /sexy/recordings/:id - get single recording
* POST /sexy/recordings - create new recording with validation
* PATCH /sexy/recordings/:id - update recording (owner only)
* DELETE /sexy/recordings/:id - soft delete by archiving
- Added TypeScript types: RecordedEvent, DeviceInfo, Recording
- Created frontend services: getRecordings(), deleteRecording()
- Built RecordingCard component with stats, device info, and actions
- Added Recordings tab to /me dashboard page with grid layout
- Added i18n translations for recordings UI
**Phase 2: Recording Capture**
- Implemented recording state management in /play page
- Added Start/Stop Recording buttons with visual indicators
- Capture device events with precise timestamps during recording
- Normalize actuator values (0-100) for cross-device compatibility
- Created RecordingSaveDialog component with:
* Recording stats display (duration, events, devices)
* Form inputs (title, description, tags)
* Device information preview
- Integrated save recording API call from play page
- Added success/error toast notifications
- Automatic event filtering during recording
**Technical Details**
- Events stored as JSON array with timestamp, deviceIndex, deviceName,
actuatorIndex, actuatorType, and normalized value
- Device metadata includes name, index, and capability list
- Slug auto-generated from title for SEO-friendly URLs
- Status workflow: draft → published → archived
- Permission checks: users can only access own recordings or public ones
- Frontend uses performance.now() for millisecond precision timing
**User Flow**
1. User scans and connects devices on /play page
2. Clicks "Start Recording" to begin capturing events
3. Manipulates device sliders - all changes are recorded
4. Clicks "Stop Recording" to end capture
5. Save dialog appears with recording preview and form
6. User enters title, description, tags and saves
7. Recording appears in dashboard /me Recordings tab
8. Can play back, edit, or delete recordings
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-28 04:05:09 +01:00
|
|
|
if (!accountability?.user) {
|
|
|
|
|
return res.status(401).json({ error: "Unauthorized" });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const recordingsService = new ItemsService("sexy_recordings", {
|
|
|
|
|
schema: await getSchema(),
|
|
|
|
|
accountability,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const { status, tags, linked_video, limit, page } = req.query;
|
|
|
|
|
const filter: any = {
|
|
|
|
|
user_created: {
|
|
|
|
|
_eq: accountability.user,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (status) filter.status = { _eq: status };
|
|
|
|
|
if (tags) filter.tags = { _contains: tags };
|
|
|
|
|
if (linked_video) filter.linked_video = { _eq: linked_video };
|
|
|
|
|
|
|
|
|
|
const recordings = await recordingsService.readByQuery({
|
|
|
|
|
filter,
|
|
|
|
|
limit: limit ? parseInt(limit as string) : 50,
|
|
|
|
|
page: page ? parseInt(page as string) : 1,
|
|
|
|
|
sort: ["-date_created"],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
res.json(recordings);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// GET /sexy/recordings/:id - Get single recording
|
|
|
|
|
router.get("/recordings/:id", async (req, res) => {
|
2025-10-28 05:16:36 +01:00
|
|
|
const accountability = req.accountability;
|
feat: add buttplug device recording feature (Phase 1 & 2)
Implemented complete infrastructure for recording, saving, and managing
buttplug device patterns with precise event timing.
**Phase 1: Backend & Infrastructure**
- Added Directus schema for sexy_recordings collection with all fields
(id, status, user_created, title, description, slug, duration, events,
device_info, tags, linked_video, featured, public)
- Created REST API endpoints in bundle extension:
* GET /sexy/recordings - list user recordings with filtering
* GET /sexy/recordings/:id - get single recording
* POST /sexy/recordings - create new recording with validation
* PATCH /sexy/recordings/:id - update recording (owner only)
* DELETE /sexy/recordings/:id - soft delete by archiving
- Added TypeScript types: RecordedEvent, DeviceInfo, Recording
- Created frontend services: getRecordings(), deleteRecording()
- Built RecordingCard component with stats, device info, and actions
- Added Recordings tab to /me dashboard page with grid layout
- Added i18n translations for recordings UI
**Phase 2: Recording Capture**
- Implemented recording state management in /play page
- Added Start/Stop Recording buttons with visual indicators
- Capture device events with precise timestamps during recording
- Normalize actuator values (0-100) for cross-device compatibility
- Created RecordingSaveDialog component with:
* Recording stats display (duration, events, devices)
* Form inputs (title, description, tags)
* Device information preview
- Integrated save recording API call from play page
- Added success/error toast notifications
- Automatic event filtering during recording
**Technical Details**
- Events stored as JSON array with timestamp, deviceIndex, deviceName,
actuatorIndex, actuatorType, and normalized value
- Device metadata includes name, index, and capability list
- Slug auto-generated from title for SEO-friendly URLs
- Status workflow: draft → published → archived
- Permission checks: users can only access own recordings or public ones
- Frontend uses performance.now() for millisecond precision timing
**User Flow**
1. User scans and connects devices on /play page
2. Clicks "Start Recording" to begin capturing events
3. Manipulates device sliders - all changes are recorded
4. Clicks "Stop Recording" to end capture
5. Save dialog appears with recording preview and form
6. User enters title, description, tags and saves
7. Recording appears in dashboard /me Recordings tab
8. Can play back, edit, or delete recordings
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-28 04:05:09 +01:00
|
|
|
if (!accountability?.user) {
|
|
|
|
|
return res.status(401).json({ error: "Unauthorized" });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const recordingsService = new ItemsService("sexy_recordings", {
|
|
|
|
|
schema: await getSchema(),
|
|
|
|
|
accountability,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const recording = await recordingsService.readOne(req.params.id);
|
|
|
|
|
|
|
|
|
|
// Check if user owns the recording or if it's public
|
|
|
|
|
if (
|
|
|
|
|
recording.user_created !== accountability.user &&
|
|
|
|
|
!recording.public
|
|
|
|
|
) {
|
|
|
|
|
return res.status(403).json({ error: "Forbidden" });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
res.json(recording);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
res.status(404).json({ error: "Recording not found" });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// POST /sexy/recordings - Create new recording
|
|
|
|
|
router.post("/recordings", async (req, res) => {
|
2025-10-28 05:16:36 +01:00
|
|
|
const accountability = req.accountability;
|
feat: add buttplug device recording feature (Phase 1 & 2)
Implemented complete infrastructure for recording, saving, and managing
buttplug device patterns with precise event timing.
**Phase 1: Backend & Infrastructure**
- Added Directus schema for sexy_recordings collection with all fields
(id, status, user_created, title, description, slug, duration, events,
device_info, tags, linked_video, featured, public)
- Created REST API endpoints in bundle extension:
* GET /sexy/recordings - list user recordings with filtering
* GET /sexy/recordings/:id - get single recording
* POST /sexy/recordings - create new recording with validation
* PATCH /sexy/recordings/:id - update recording (owner only)
* DELETE /sexy/recordings/:id - soft delete by archiving
- Added TypeScript types: RecordedEvent, DeviceInfo, Recording
- Created frontend services: getRecordings(), deleteRecording()
- Built RecordingCard component with stats, device info, and actions
- Added Recordings tab to /me dashboard page with grid layout
- Added i18n translations for recordings UI
**Phase 2: Recording Capture**
- Implemented recording state management in /play page
- Added Start/Stop Recording buttons with visual indicators
- Capture device events with precise timestamps during recording
- Normalize actuator values (0-100) for cross-device compatibility
- Created RecordingSaveDialog component with:
* Recording stats display (duration, events, devices)
* Form inputs (title, description, tags)
* Device information preview
- Integrated save recording API call from play page
- Added success/error toast notifications
- Automatic event filtering during recording
**Technical Details**
- Events stored as JSON array with timestamp, deviceIndex, deviceName,
actuatorIndex, actuatorType, and normalized value
- Device metadata includes name, index, and capability list
- Slug auto-generated from title for SEO-friendly URLs
- Status workflow: draft → published → archived
- Permission checks: users can only access own recordings or public ones
- Frontend uses performance.now() for millisecond precision timing
**User Flow**
1. User scans and connects devices on /play page
2. Clicks "Start Recording" to begin capturing events
3. Manipulates device sliders - all changes are recorded
4. Clicks "Stop Recording" to end capture
5. Save dialog appears with recording preview and form
6. User enters title, description, tags and saves
7. Recording appears in dashboard /me Recordings tab
8. Can play back, edit, or delete recordings
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-28 04:05:09 +01:00
|
|
|
if (!accountability?.user) {
|
|
|
|
|
return res.status(401).json({ error: "Unauthorized" });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const recordingsService = new ItemsService("sexy_recordings", {
|
|
|
|
|
schema: await getSchema(),
|
|
|
|
|
accountability,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const { title, description, duration, events, device_info, tags, linked_video, status } = req.body;
|
|
|
|
|
|
|
|
|
|
// Validate required fields
|
|
|
|
|
if (!title || !duration || !events || !device_info) {
|
|
|
|
|
return res.status(400).json({
|
|
|
|
|
error: "Missing required fields: title, duration, events, device_info",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate events structure
|
|
|
|
|
if (!Array.isArray(events) || events.length === 0) {
|
|
|
|
|
return res.status(400).json({ error: "Events must be a non-empty array" });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Generate slug from title
|
|
|
|
|
const slug = title
|
|
|
|
|
.toLowerCase()
|
|
|
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
|
|
|
.replace(/^-|-$/g, "");
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const recording = await recordingsService.createOne({
|
|
|
|
|
title,
|
|
|
|
|
description,
|
|
|
|
|
slug,
|
|
|
|
|
duration,
|
|
|
|
|
events,
|
|
|
|
|
device_info,
|
2025-10-28 13:44:35 +01:00
|
|
|
tags: Array.isArray(tags) ? tags : (typeof tags === 'string' ? (tags.startsWith('[') ? JSON.parse(tags) : []) : []),
|
feat: add buttplug device recording feature (Phase 1 & 2)
Implemented complete infrastructure for recording, saving, and managing
buttplug device patterns with precise event timing.
**Phase 1: Backend & Infrastructure**
- Added Directus schema for sexy_recordings collection with all fields
(id, status, user_created, title, description, slug, duration, events,
device_info, tags, linked_video, featured, public)
- Created REST API endpoints in bundle extension:
* GET /sexy/recordings - list user recordings with filtering
* GET /sexy/recordings/:id - get single recording
* POST /sexy/recordings - create new recording with validation
* PATCH /sexy/recordings/:id - update recording (owner only)
* DELETE /sexy/recordings/:id - soft delete by archiving
- Added TypeScript types: RecordedEvent, DeviceInfo, Recording
- Created frontend services: getRecordings(), deleteRecording()
- Built RecordingCard component with stats, device info, and actions
- Added Recordings tab to /me dashboard page with grid layout
- Added i18n translations for recordings UI
**Phase 2: Recording Capture**
- Implemented recording state management in /play page
- Added Start/Stop Recording buttons with visual indicators
- Capture device events with precise timestamps during recording
- Normalize actuator values (0-100) for cross-device compatibility
- Created RecordingSaveDialog component with:
* Recording stats display (duration, events, devices)
* Form inputs (title, description, tags)
* Device information preview
- Integrated save recording API call from play page
- Added success/error toast notifications
- Automatic event filtering during recording
**Technical Details**
- Events stored as JSON array with timestamp, deviceIndex, deviceName,
actuatorIndex, actuatorType, and normalized value
- Device metadata includes name, index, and capability list
- Slug auto-generated from title for SEO-friendly URLs
- Status workflow: draft → published → archived
- Permission checks: users can only access own recordings or public ones
- Frontend uses performance.now() for millisecond precision timing
**User Flow**
1. User scans and connects devices on /play page
2. Clicks "Start Recording" to begin capturing events
3. Manipulates device sliders - all changes are recorded
4. Clicks "Stop Recording" to end capture
5. Save dialog appears with recording preview and form
6. User enters title, description, tags and saves
7. Recording appears in dashboard /me Recordings tab
8. Can play back, edit, or delete recordings
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-28 04:05:09 +01:00
|
|
|
linked_video: linked_video || null,
|
|
|
|
|
status: status || "draft",
|
|
|
|
|
public: false,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
res.status(201).json(recording);
|
|
|
|
|
} catch (error: any) {
|
2025-10-28 05:16:36 +01:00
|
|
|
console.error("Failed to create recording:", error);
|
|
|
|
|
res.status(500).json({
|
|
|
|
|
error: error.message || "Failed to create recording",
|
|
|
|
|
details: error.toString()
|
|
|
|
|
});
|
feat: add buttplug device recording feature (Phase 1 & 2)
Implemented complete infrastructure for recording, saving, and managing
buttplug device patterns with precise event timing.
**Phase 1: Backend & Infrastructure**
- Added Directus schema for sexy_recordings collection with all fields
(id, status, user_created, title, description, slug, duration, events,
device_info, tags, linked_video, featured, public)
- Created REST API endpoints in bundle extension:
* GET /sexy/recordings - list user recordings with filtering
* GET /sexy/recordings/:id - get single recording
* POST /sexy/recordings - create new recording with validation
* PATCH /sexy/recordings/:id - update recording (owner only)
* DELETE /sexy/recordings/:id - soft delete by archiving
- Added TypeScript types: RecordedEvent, DeviceInfo, Recording
- Created frontend services: getRecordings(), deleteRecording()
- Built RecordingCard component with stats, device info, and actions
- Added Recordings tab to /me dashboard page with grid layout
- Added i18n translations for recordings UI
**Phase 2: Recording Capture**
- Implemented recording state management in /play page
- Added Start/Stop Recording buttons with visual indicators
- Capture device events with precise timestamps during recording
- Normalize actuator values (0-100) for cross-device compatibility
- Created RecordingSaveDialog component with:
* Recording stats display (duration, events, devices)
* Form inputs (title, description, tags)
* Device information preview
- Integrated save recording API call from play page
- Added success/error toast notifications
- Automatic event filtering during recording
**Technical Details**
- Events stored as JSON array with timestamp, deviceIndex, deviceName,
actuatorIndex, actuatorType, and normalized value
- Device metadata includes name, index, and capability list
- Slug auto-generated from title for SEO-friendly URLs
- Status workflow: draft → published → archived
- Permission checks: users can only access own recordings or public ones
- Frontend uses performance.now() for millisecond precision timing
**User Flow**
1. User scans and connects devices on /play page
2. Clicks "Start Recording" to begin capturing events
3. Manipulates device sliders - all changes are recorded
4. Clicks "Stop Recording" to end capture
5. Save dialog appears with recording preview and form
6. User enters title, description, tags and saves
7. Recording appears in dashboard /me Recordings tab
8. Can play back, edit, or delete recordings
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-28 04:05:09 +01:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// PATCH /sexy/recordings/:id - Update recording
|
|
|
|
|
router.patch("/recordings/:id", async (req, res) => {
|
2025-10-28 05:16:36 +01:00
|
|
|
const accountability = req.accountability;
|
feat: add buttplug device recording feature (Phase 1 & 2)
Implemented complete infrastructure for recording, saving, and managing
buttplug device patterns with precise event timing.
**Phase 1: Backend & Infrastructure**
- Added Directus schema for sexy_recordings collection with all fields
(id, status, user_created, title, description, slug, duration, events,
device_info, tags, linked_video, featured, public)
- Created REST API endpoints in bundle extension:
* GET /sexy/recordings - list user recordings with filtering
* GET /sexy/recordings/:id - get single recording
* POST /sexy/recordings - create new recording with validation
* PATCH /sexy/recordings/:id - update recording (owner only)
* DELETE /sexy/recordings/:id - soft delete by archiving
- Added TypeScript types: RecordedEvent, DeviceInfo, Recording
- Created frontend services: getRecordings(), deleteRecording()
- Built RecordingCard component with stats, device info, and actions
- Added Recordings tab to /me dashboard page with grid layout
- Added i18n translations for recordings UI
**Phase 2: Recording Capture**
- Implemented recording state management in /play page
- Added Start/Stop Recording buttons with visual indicators
- Capture device events with precise timestamps during recording
- Normalize actuator values (0-100) for cross-device compatibility
- Created RecordingSaveDialog component with:
* Recording stats display (duration, events, devices)
* Form inputs (title, description, tags)
* Device information preview
- Integrated save recording API call from play page
- Added success/error toast notifications
- Automatic event filtering during recording
**Technical Details**
- Events stored as JSON array with timestamp, deviceIndex, deviceName,
actuatorIndex, actuatorType, and normalized value
- Device metadata includes name, index, and capability list
- Slug auto-generated from title for SEO-friendly URLs
- Status workflow: draft → published → archived
- Permission checks: users can only access own recordings or public ones
- Frontend uses performance.now() for millisecond precision timing
**User Flow**
1. User scans and connects devices on /play page
2. Clicks "Start Recording" to begin capturing events
3. Manipulates device sliders - all changes are recorded
4. Clicks "Stop Recording" to end capture
5. Save dialog appears with recording preview and form
6. User enters title, description, tags and saves
7. Recording appears in dashboard /me Recordings tab
8. Can play back, edit, or delete recordings
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-28 04:05:09 +01:00
|
|
|
if (!accountability?.user) {
|
|
|
|
|
return res.status(401).json({ error: "Unauthorized" });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const recordingsService = new ItemsService("sexy_recordings", {
|
|
|
|
|
schema: await getSchema(),
|
|
|
|
|
accountability,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const existing = await recordingsService.readOne(req.params.id);
|
|
|
|
|
|
|
|
|
|
// Only allow owner to update
|
|
|
|
|
if (existing.user_created !== accountability.user) {
|
|
|
|
|
return res.status(403).json({ error: "Forbidden" });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { title, description, tags, status, public: isPublic, linked_video } = req.body;
|
|
|
|
|
const updates: any = {};
|
|
|
|
|
|
|
|
|
|
if (title !== undefined) {
|
|
|
|
|
updates.title = title;
|
|
|
|
|
updates.slug = title
|
|
|
|
|
.toLowerCase()
|
|
|
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
|
|
|
.replace(/^-|-$/g, "");
|
|
|
|
|
}
|
|
|
|
|
if (description !== undefined) updates.description = description;
|
|
|
|
|
if (tags !== undefined) updates.tags = tags;
|
|
|
|
|
if (status !== undefined) updates.status = status;
|
|
|
|
|
if (isPublic !== undefined) updates.public = isPublic;
|
|
|
|
|
if (linked_video !== undefined) updates.linked_video = linked_video;
|
|
|
|
|
|
|
|
|
|
const recording = await recordingsService.updateOne(req.params.id, updates);
|
|
|
|
|
res.json(recording);
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
res.status(500).json({ error: error.message || "Failed to update recording" });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// DELETE /sexy/recordings/:id - Delete (archive) recording
|
|
|
|
|
router.delete("/recordings/:id", async (req, res) => {
|
2025-10-28 05:16:36 +01:00
|
|
|
const accountability = req.accountability;
|
feat: add buttplug device recording feature (Phase 1 & 2)
Implemented complete infrastructure for recording, saving, and managing
buttplug device patterns with precise event timing.
**Phase 1: Backend & Infrastructure**
- Added Directus schema for sexy_recordings collection with all fields
(id, status, user_created, title, description, slug, duration, events,
device_info, tags, linked_video, featured, public)
- Created REST API endpoints in bundle extension:
* GET /sexy/recordings - list user recordings with filtering
* GET /sexy/recordings/:id - get single recording
* POST /sexy/recordings - create new recording with validation
* PATCH /sexy/recordings/:id - update recording (owner only)
* DELETE /sexy/recordings/:id - soft delete by archiving
- Added TypeScript types: RecordedEvent, DeviceInfo, Recording
- Created frontend services: getRecordings(), deleteRecording()
- Built RecordingCard component with stats, device info, and actions
- Added Recordings tab to /me dashboard page with grid layout
- Added i18n translations for recordings UI
**Phase 2: Recording Capture**
- Implemented recording state management in /play page
- Added Start/Stop Recording buttons with visual indicators
- Capture device events with precise timestamps during recording
- Normalize actuator values (0-100) for cross-device compatibility
- Created RecordingSaveDialog component with:
* Recording stats display (duration, events, devices)
* Form inputs (title, description, tags)
* Device information preview
- Integrated save recording API call from play page
- Added success/error toast notifications
- Automatic event filtering during recording
**Technical Details**
- Events stored as JSON array with timestamp, deviceIndex, deviceName,
actuatorIndex, actuatorType, and normalized value
- Device metadata includes name, index, and capability list
- Slug auto-generated from title for SEO-friendly URLs
- Status workflow: draft → published → archived
- Permission checks: users can only access own recordings or public ones
- Frontend uses performance.now() for millisecond precision timing
**User Flow**
1. User scans and connects devices on /play page
2. Clicks "Start Recording" to begin capturing events
3. Manipulates device sliders - all changes are recorded
4. Clicks "Stop Recording" to end capture
5. Save dialog appears with recording preview and form
6. User enters title, description, tags and saves
7. Recording appears in dashboard /me Recordings tab
8. Can play back, edit, or delete recordings
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-28 04:05:09 +01:00
|
|
|
if (!accountability?.user) {
|
|
|
|
|
return res.status(401).json({ error: "Unauthorized" });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const recordingsService = new ItemsService("sexy_recordings", {
|
|
|
|
|
schema: await getSchema(),
|
|
|
|
|
accountability,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const existing = await recordingsService.readOne(req.params.id);
|
|
|
|
|
|
|
|
|
|
// Only allow owner to delete
|
|
|
|
|
if (existing.user_created !== accountability.user) {
|
|
|
|
|
return res.status(403).json({ error: "Forbidden" });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Soft delete by setting status to archived
|
|
|
|
|
await recordingsService.updateOne(req.params.id, {
|
|
|
|
|
status: "archived",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
res.json({ success: true });
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
res.status(500).json({ error: error.message || "Failed to delete recording" });
|
|
|
|
|
}
|
|
|
|
|
});
|
2025-10-28 10:29:02 +01:00
|
|
|
|
|
|
|
|
// POST /sexy/videos/:id/like - Like a video
|
|
|
|
|
router.post("/videos/:id/like", async (req, res) => {
|
|
|
|
|
const accountability = req.accountability;
|
|
|
|
|
if (!accountability?.user) {
|
|
|
|
|
return res.status(401).json({ error: "Unauthorized" });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const videoId = req.params.id;
|
|
|
|
|
const userId = accountability.user;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const likesService = new ItemsService("sexy_video_likes", {
|
|
|
|
|
schema: await getSchema(),
|
|
|
|
|
accountability,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Check if already liked
|
|
|
|
|
const existing = await likesService.readByQuery({
|
|
|
|
|
filter: { video_id: videoId, user_id: userId },
|
|
|
|
|
limit: 1,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (existing.length > 0) {
|
|
|
|
|
return res.status(400).json({ error: "Already liked" });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create like
|
|
|
|
|
await likesService.createOne({
|
|
|
|
|
video_id: videoId,
|
|
|
|
|
user_id: userId,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Increment likes_count
|
|
|
|
|
const videosService = new ItemsService("sexy_videos", {
|
|
|
|
|
schema: await getSchema(),
|
|
|
|
|
});
|
|
|
|
|
const video = await videosService.readOne(videoId);
|
|
|
|
|
await videosService.updateOne(videoId, {
|
|
|
|
|
likes_count: (video.likes_count || 0) + 1,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
res.json({ liked: true, likes_count: (video.likes_count || 0) + 1 });
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
res.status(500).json({ error: error.message || "Failed to like video" });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// DELETE /sexy/videos/:id/like - Unlike a video
|
|
|
|
|
router.delete("/videos/:id/like", async (req, res) => {
|
|
|
|
|
const accountability = req.accountability;
|
|
|
|
|
if (!accountability?.user) {
|
|
|
|
|
return res.status(401).json({ error: "Unauthorized" });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const videoId = req.params.id;
|
|
|
|
|
const userId = accountability.user;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const likesService = new ItemsService("sexy_video_likes", {
|
|
|
|
|
schema: await getSchema(),
|
|
|
|
|
accountability,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Find and delete like
|
|
|
|
|
const existing = await likesService.readByQuery({
|
|
|
|
|
filter: { video_id: videoId, user_id: userId },
|
|
|
|
|
limit: 1,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (existing.length === 0) {
|
|
|
|
|
return res.status(400).json({ error: "Not liked" });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await likesService.deleteOne(existing[0].id);
|
|
|
|
|
|
|
|
|
|
// Decrement likes_count
|
|
|
|
|
const videosService = new ItemsService("sexy_videos", {
|
|
|
|
|
schema: await getSchema(),
|
|
|
|
|
});
|
|
|
|
|
const video = await videosService.readOne(videoId);
|
|
|
|
|
await videosService.updateOne(videoId, {
|
|
|
|
|
likes_count: Math.max((video.likes_count || 0) - 1, 0),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
res.json({ liked: false, likes_count: Math.max((video.likes_count || 0) - 1, 0) });
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
res.status(500).json({ error: error.message || "Failed to unlike video" });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// GET /sexy/videos/:id/like-status - Get like status for a video
|
|
|
|
|
router.get("/videos/:id/like-status", async (req, res) => {
|
|
|
|
|
const accountability = req.accountability;
|
|
|
|
|
if (!accountability?.user) {
|
|
|
|
|
return res.json({ liked: false });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const videoId = req.params.id;
|
|
|
|
|
const userId = accountability.user;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const likesService = new ItemsService("sexy_video_likes", {
|
|
|
|
|
schema: await getSchema(),
|
|
|
|
|
accountability,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const existing = await likesService.readByQuery({
|
|
|
|
|
filter: { video_id: videoId, user_id: userId },
|
|
|
|
|
limit: 1,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
res.json({ liked: existing.length > 0 });
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
res.status(500).json({ error: error.message || "Failed to get like status" });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// POST /sexy/videos/:id/play - Record a video play
|
|
|
|
|
router.post("/videos/:id/play", async (req, res) => {
|
|
|
|
|
const accountability = req.accountability;
|
|
|
|
|
const videoId = req.params.id;
|
|
|
|
|
const { session_id } = req.body;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const playsService = new ItemsService("sexy_video_plays", {
|
|
|
|
|
schema: await getSchema(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const videosService = new ItemsService("sexy_videos", {
|
|
|
|
|
schema: await getSchema(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Record play
|
|
|
|
|
const play = await playsService.createOne({
|
|
|
|
|
video_id: videoId,
|
|
|
|
|
user_id: accountability?.user || null,
|
|
|
|
|
session_id: session_id || null,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Increment plays_count
|
|
|
|
|
const video = await videosService.readOne(videoId);
|
|
|
|
|
await videosService.updateOne(videoId, {
|
|
|
|
|
plays_count: (video.plays_count || 0) + 1,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
res.json({ success: true, play_id: play, plays_count: (video.plays_count || 0) + 1 });
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
res.status(500).json({ error: error.message || "Failed to record play" });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// PATCH /sexy/videos/:id/play/:playId - Update play progress
|
|
|
|
|
router.patch("/videos/:id/play/:playId", async (req, res) => {
|
|
|
|
|
const { playId } = req.params;
|
|
|
|
|
const { duration_watched, completed } = req.body;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const playsService = new ItemsService("sexy_video_plays", {
|
|
|
|
|
schema: await getSchema(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await playsService.updateOne(playId, {
|
|
|
|
|
duration_watched,
|
|
|
|
|
completed,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
res.json({ success: true });
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
res.status(500).json({ error: error.message || "Failed to update play" });
|
|
|
|
|
}
|
|
|
|
|
});
|
2025-10-28 10:42:06 +01:00
|
|
|
|
|
|
|
|
// GET /sexy/analytics - Get analytics for the authenticated user's content
|
|
|
|
|
router.get("/analytics", async (req, res) => {
|
|
|
|
|
const accountability = req.accountability;
|
|
|
|
|
if (!accountability?.user) {
|
|
|
|
|
return res.status(401).json({ error: "Unauthorized" });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const userId = accountability.user;
|
|
|
|
|
|
|
|
|
|
// Get all videos by this user
|
|
|
|
|
const videosService = new ItemsService("sexy_videos", {
|
|
|
|
|
schema: await getSchema(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const videos = await videosService.readByQuery({
|
|
|
|
|
filter: {
|
|
|
|
|
models: {
|
|
|
|
|
directus_users_id: {
|
|
|
|
|
_eq: userId,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
fields: ["id", "title", "slug", "likes_count", "plays_count", "upload_date"],
|
|
|
|
|
limit: -1,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (videos.length === 0) {
|
|
|
|
|
return res.json({
|
|
|
|
|
total_videos: 0,
|
|
|
|
|
total_likes: 0,
|
|
|
|
|
total_plays: 0,
|
|
|
|
|
videos: [],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const videoIds = videos.map((v) => v.id);
|
|
|
|
|
|
|
|
|
|
// Get play analytics
|
|
|
|
|
const playsService = new ItemsService("sexy_video_plays", {
|
|
|
|
|
schema: await getSchema(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const plays = await playsService.readByQuery({
|
|
|
|
|
filter: {
|
|
|
|
|
video_id: {
|
|
|
|
|
_in: videoIds,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
fields: ["video_id", "date_created", "duration_watched", "completed"],
|
|
|
|
|
limit: -1,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Get like analytics
|
|
|
|
|
const likesService = new ItemsService("sexy_video_likes", {
|
|
|
|
|
schema: await getSchema(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const likes = await likesService.readByQuery({
|
|
|
|
|
filter: {
|
|
|
|
|
video_id: {
|
|
|
|
|
_in: videoIds,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
fields: ["video_id", "date_created"],
|
|
|
|
|
limit: -1,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Calculate totals
|
|
|
|
|
const totalLikes = videos.reduce((sum, v) => sum + (v.likes_count || 0), 0);
|
|
|
|
|
const totalPlays = videos.reduce((sum, v) => sum + (v.plays_count || 0), 0);
|
|
|
|
|
|
|
|
|
|
// Group plays by date for timeline
|
|
|
|
|
const playsByDate = plays.reduce((acc, play) => {
|
|
|
|
|
const date = new Date(play.date_created).toISOString().split("T")[0];
|
|
|
|
|
if (!acc[date]) acc[date] = 0;
|
|
|
|
|
acc[date]++;
|
|
|
|
|
return acc;
|
|
|
|
|
}, {});
|
|
|
|
|
|
|
|
|
|
// Group likes by date for timeline
|
|
|
|
|
const likesByDate = likes.reduce((acc, like) => {
|
|
|
|
|
const date = new Date(like.date_created).toISOString().split("T")[0];
|
|
|
|
|
if (!acc[date]) acc[date] = 0;
|
|
|
|
|
acc[date]++;
|
|
|
|
|
return acc;
|
|
|
|
|
}, {});
|
|
|
|
|
|
|
|
|
|
// Video-specific analytics
|
|
|
|
|
const videoAnalytics = videos.map((video) => {
|
|
|
|
|
const videoPlays = plays.filter((p) => p.video_id === video.id);
|
|
|
|
|
const completedPlays = videoPlays.filter((p) => p.completed).length;
|
|
|
|
|
const avgWatchTime =
|
|
|
|
|
videoPlays.length > 0
|
|
|
|
|
? videoPlays.reduce((sum, p) => sum + (p.duration_watched || 0), 0) /
|
|
|
|
|
videoPlays.length
|
|
|
|
|
: 0;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
id: video.id,
|
|
|
|
|
title: video.title,
|
|
|
|
|
slug: video.slug,
|
|
|
|
|
upload_date: video.upload_date,
|
|
|
|
|
likes: video.likes_count || 0,
|
|
|
|
|
plays: video.plays_count || 0,
|
|
|
|
|
completed_plays: completedPlays,
|
|
|
|
|
completion_rate: video.plays_count ? (completedPlays / video.plays_count) * 100 : 0,
|
|
|
|
|
avg_watch_time: Math.round(avgWatchTime),
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
res.json({
|
|
|
|
|
total_videos: videos.length,
|
|
|
|
|
total_likes: totalLikes,
|
|
|
|
|
total_plays: totalPlays,
|
|
|
|
|
plays_by_date: playsByDate,
|
|
|
|
|
likes_by_date: likesByDate,
|
|
|
|
|
videos: videoAnalytics,
|
|
|
|
|
});
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
console.error("Analytics error:", error);
|
|
|
|
|
res.status(500).json({ error: error.message || "Failed to get analytics" });
|
|
|
|
|
}
|
|
|
|
|
});
|
2025-10-28 13:21:51 +01:00
|
|
|
|
|
|
|
|
// =========================================
|
|
|
|
|
// GAMIFICATION ENDPOINTS
|
|
|
|
|
// =========================================
|
|
|
|
|
|
|
|
|
|
// GET /sexy/gamification/leaderboard - Get top users by weighted score
|
|
|
|
|
router.get("/gamification/leaderboard", async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const limit = Math.min(parseInt(req.query.limit as string) || 100, 500);
|
|
|
|
|
const offset = parseInt(req.query.offset as string) || 0;
|
|
|
|
|
|
|
|
|
|
const leaderboard = await database("sexy_user_stats as s")
|
|
|
|
|
.leftJoin("directus_users as u", "s.user_id", "u.id")
|
|
|
|
|
.select(
|
|
|
|
|
"u.id as user_id",
|
|
|
|
|
"u.artist_name as display_name",
|
|
|
|
|
"u.avatar",
|
|
|
|
|
"s.total_weighted_points",
|
|
|
|
|
"s.total_raw_points",
|
|
|
|
|
"s.recordings_count",
|
|
|
|
|
"s.playbacks_count",
|
|
|
|
|
"s.achievements_count",
|
|
|
|
|
)
|
|
|
|
|
.orderBy("s.total_weighted_points", "desc")
|
|
|
|
|
.limit(limit)
|
|
|
|
|
.offset(offset);
|
|
|
|
|
|
|
|
|
|
// Add rank to each entry
|
|
|
|
|
const leaderboardWithRank = leaderboard.map((entry, index) => ({
|
|
|
|
|
...entry,
|
|
|
|
|
rank: offset + index + 1,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
res.json({ data: leaderboardWithRank });
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
console.error("Leaderboard error:", error);
|
|
|
|
|
res.status(500).json({ error: error.message || "Failed to get leaderboard" });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// GET /sexy/gamification/user/:id - Get gamification stats for a user
|
|
|
|
|
router.get("/gamification/user/:id", async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const { id } = req.params;
|
|
|
|
|
|
|
|
|
|
// Get user stats
|
|
|
|
|
const stats = await database("sexy_user_stats")
|
|
|
|
|
.where({ user_id: id })
|
|
|
|
|
.first();
|
|
|
|
|
|
|
|
|
|
// Calculate rank
|
|
|
|
|
let rank = 1;
|
|
|
|
|
if (stats) {
|
|
|
|
|
const rankResult = await database("sexy_user_stats")
|
|
|
|
|
.where("total_weighted_points", ">", stats.total_weighted_points)
|
|
|
|
|
.count("* as count");
|
|
|
|
|
rank = (rankResult[0]?.count || 0) + 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get unlocked achievements
|
|
|
|
|
const achievements = await database("sexy_user_achievements as ua")
|
|
|
|
|
.leftJoin("sexy_achievements as a", "ua.achievement_id", "a.id")
|
|
|
|
|
.where({ "ua.user_id": id })
|
|
|
|
|
.whereNotNull("ua.date_unlocked")
|
|
|
|
|
.select(
|
|
|
|
|
"a.id",
|
|
|
|
|
"a.code",
|
|
|
|
|
"a.name",
|
|
|
|
|
"a.description",
|
|
|
|
|
"a.icon",
|
|
|
|
|
"a.category",
|
|
|
|
|
"ua.date_unlocked",
|
|
|
|
|
"ua.progress",
|
|
|
|
|
"a.required_count",
|
|
|
|
|
)
|
|
|
|
|
.orderBy("ua.date_unlocked", "desc");
|
|
|
|
|
|
|
|
|
|
// Get recent points
|
|
|
|
|
const recentPoints = await database("sexy_user_points")
|
|
|
|
|
.where({ user_id: id })
|
|
|
|
|
.select("action", "points", "date_created", "recording_id")
|
|
|
|
|
.orderBy("date_created", "desc")
|
|
|
|
|
.limit(10);
|
|
|
|
|
|
|
|
|
|
res.json({
|
|
|
|
|
stats: stats ? { ...stats, rank } : null,
|
|
|
|
|
achievements,
|
|
|
|
|
recent_points: recentPoints,
|
|
|
|
|
});
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
console.error("User gamification error:", error);
|
|
|
|
|
res.status(500).json({ error: error.message || "Failed to get user gamification data" });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// GET /sexy/gamification/achievements - Get all achievements
|
|
|
|
|
router.get("/gamification/achievements", async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const achievements = await database("sexy_achievements")
|
|
|
|
|
.where({ status: "published" })
|
|
|
|
|
.select(
|
|
|
|
|
"id",
|
|
|
|
|
"code",
|
|
|
|
|
"name",
|
|
|
|
|
"description",
|
|
|
|
|
"icon",
|
|
|
|
|
"category",
|
|
|
|
|
"required_count",
|
|
|
|
|
"points_reward",
|
|
|
|
|
)
|
|
|
|
|
.orderBy("sort", "asc");
|
|
|
|
|
|
|
|
|
|
res.json({ data: achievements });
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
console.error("Achievements error:", error);
|
|
|
|
|
res.status(500).json({ error: error.message || "Failed to get achievements" });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// POST /sexy/recordings/:id/play - Record a recording play (with gamification)
|
|
|
|
|
router.post("/recordings/:id/play", async (req, res) => {
|
|
|
|
|
const accountability = req.accountability;
|
|
|
|
|
const recordingId = req.params.id;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Get recording to check ownership
|
|
|
|
|
const recording = await database("sexy_recordings")
|
|
|
|
|
.where({ id: recordingId })
|
|
|
|
|
.first();
|
|
|
|
|
|
|
|
|
|
if (!recording) {
|
|
|
|
|
return res.status(404).json({ error: "Recording not found" });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Record play
|
|
|
|
|
const play = await database("sexy_recording_plays").insert({
|
|
|
|
|
user_id: accountability?.user || null,
|
|
|
|
|
recording_id: recordingId,
|
|
|
|
|
duration_played: 0,
|
|
|
|
|
completed: false,
|
|
|
|
|
date_created: new Date(),
|
|
|
|
|
}).returning("id");
|
|
|
|
|
|
|
|
|
|
const playId = play[0]?.id || play[0];
|
|
|
|
|
|
|
|
|
|
// Award points if user is authenticated and not playing own recording
|
|
|
|
|
if (accountability?.user && recording.user_created !== accountability.user) {
|
|
|
|
|
const { awardPoints, POINT_VALUES } = await import("./gamification");
|
|
|
|
|
await awardPoints(database, accountability.user, "RECORDING_PLAY", recordingId);
|
|
|
|
|
await checkAchievements(database, accountability.user, "playback");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
res.json({ success: true, play_id: playId });
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
console.error("Recording play error:", error);
|
|
|
|
|
res.status(500).json({ error: error.message || "Failed to record play" });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// PATCH /sexy/recordings/:id/play/:playId - Update play progress (with gamification)
|
|
|
|
|
router.patch("/recordings/:id/play/:playId", async (req, res) => {
|
|
|
|
|
const { playId } = req.params;
|
|
|
|
|
const { duration_played, completed } = req.body;
|
|
|
|
|
const accountability = req.accountability;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Get existing play record
|
|
|
|
|
const existingPlay = await database("sexy_recording_plays")
|
|
|
|
|
.where({ id: playId })
|
|
|
|
|
.first();
|
|
|
|
|
|
|
|
|
|
if (!existingPlay) {
|
|
|
|
|
return res.status(404).json({ error: "Play record not found" });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const wasCompleted = existingPlay.completed;
|
|
|
|
|
|
|
|
|
|
// Update play record
|
|
|
|
|
await database("sexy_recording_plays")
|
|
|
|
|
.where({ id: playId })
|
|
|
|
|
.update({
|
|
|
|
|
duration_played,
|
|
|
|
|
completed,
|
|
|
|
|
date_updated: new Date(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Award completion points if newly completed
|
|
|
|
|
if (completed && !wasCompleted && accountability?.user) {
|
|
|
|
|
const { awardPoints } = await import("./gamification");
|
|
|
|
|
await awardPoints(database, accountability.user, "RECORDING_COMPLETE", existingPlay.recording_id);
|
|
|
|
|
await checkAchievements(database, accountability.user, "playback");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
res.json({ success: true });
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
console.error("Update play error:", error);
|
|
|
|
|
res.status(500).json({ error: error.message || "Failed to update play" });
|
|
|
|
|
}
|
|
|
|
|
});
|
2025-10-25 22:04:41 +02:00
|
|
|
},
|
|
|
|
|
};
|