Kaynağa Gözat

refactor: Update share links API to use CRUD endpoints

Instead of custom C++ backend handlers, share links now use the generic
CRUD API with a `share_links` project table. This aligns with the BaaS
architecture where application-specific features use the platform's
CRUD system rather than being embedded in the backend.

Changes:
- shareLinks.ts: Rewritten to use /api/share_links CRUD endpoints
- ShareModal.tsx: Pass session tokens to API calls
- AnalyticsDashboard.tsx: Pass session tokens to listShareLinks

The share_links table will be created automatically by the CRUD API
when first accessed (if dynamic tables are enabled) or can be pre-created
via the admin panel.
Fszontagh 1 gün önce
ebeveyn
işleme
93e2628a8f

+ 188 - 22
src/api/shareLinks.ts

@@ -1,11 +1,40 @@
 import type { ShareLink } from '../types';
 
+/**
+ * Share links API using the generic CRUD endpoints.
+ * Share links are stored in the `share_links` project table.
+ *
+ * Table schema:
+ * - id: string (primary key, auto-generated)
+ * - link_code: string (unique code for the share URL)
+ * - image_id: string (references the image's id)
+ * - image_short_code: string (the image's short code for convenience)
+ * - created_by: string (user ID if authenticated)
+ * - session_token: string (for non-auth users)
+ * - expires_at: string (ISO timestamp)
+ * - view_count: integer
+ * - last_viewed_at: string (ISO timestamp, nullable)
+ * - is_revoked: boolean
+ * - created_at: string (ISO timestamp)
+ */
+
 const API_BASE = '/api';
 
+function generateLinkCode(): string {
+  const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
+  let result = '';
+  for (let i = 0; i < 12; i++) {
+    result += chars.charAt(Math.floor(Math.random() * chars.length));
+  }
+  return result;
+}
+
 export async function createShareLink(
   shortCode: string,
   expiresIn: number,
-  token?: string
+  token?: string,
+  sessionToken?: string,
+  imageId?: string
 ): Promise<{ shareUrl: string; linkCode: string; expiresAt: string }> {
   const headers: Record<string, string> = {
     'Content-Type': 'application/json',
@@ -13,11 +42,26 @@ export async function createShareLink(
   if (token) {
     headers['Authorization'] = `Bearer ${token}`;
   }
+  if (sessionToken) {
+    headers['X-Session-Token'] = sessionToken;
+  }
+
+  const linkCode = generateLinkCode();
+  const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString();
 
-  const res = await fetch(`${API_BASE}/images/${shortCode}/links`, {
+  // Create share link via CRUD API
+  const res = await fetch(`${API_BASE}/share_links`, {
     method: 'POST',
     headers,
-    body: JSON.stringify({ expires_in: expiresIn }),
+    body: JSON.stringify({
+      link_code: linkCode,
+      image_id: imageId || '',
+      image_short_code: shortCode,
+      expires_at: expiresAt,
+      view_count: 0,
+      is_revoked: false,
+      session_token: sessionToken || '',
+    }),
   });
 
   if (!res.ok) {
@@ -25,54 +69,176 @@ export async function createShareLink(
     throw new Error(error.error || 'Failed to create share link');
   }
 
-  return res.json();
+  const data = await res.json();
+  const shareUrl = `${window.location.origin}/s/${linkCode}`;
+
+  return {
+    shareUrl,
+    linkCode,
+    expiresAt: data.expires_at || expiresAt,
+  };
 }
 
 export async function listShareLinks(
   shortCode: string,
-  token?: string
+  token?: string,
+  sessionToken?: string
 ): Promise<ShareLink[]> {
   const headers: Record<string, string> = {};
   if (token) {
     headers['Authorization'] = `Bearer ${token}`;
   }
+  if (sessionToken) {
+    headers['X-Session-Token'] = sessionToken;
+  }
+
+  // Query share links filtered by image_short_code
+  const params = new URLSearchParams({
+    filter: `image_short_code.eq.${shortCode}`,
+    order: 'created_at.desc',
+  });
 
-  const res = await fetch(`${API_BASE}/images/${shortCode}/links`, { headers });
+  const res = await fetch(`${API_BASE}/share_links?${params}`, { headers });
 
   if (!res.ok) {
     throw new Error('Failed to load share links');
   }
 
   const data = await res.json();
-  return (data.links || []).map((link: Record<string, unknown>) => ({
-    id: link.id as string,
-    linkCode: link.link_code as string,
-    shareUrl: link.share_url as string,
-    expiresAt: new Date(link.expires_at as string),
-    viewCount: link.view_count as number,
-    lastViewedAt: link.last_viewed_at ? new Date(link.last_viewed_at as string) : undefined,
-    isRevoked: link.is_revoked as boolean,
-    isValid: link.is_valid as boolean,
-    createdAt: new Date(link.created_at as string),
-  }));
+  const now = new Date();
+
+  return (data.data || []).map((link: Record<string, unknown>) => {
+    const expiresAt = new Date(link.expires_at as string);
+    const isExpired = expiresAt < now;
+    const isRevoked = link.is_revoked as boolean;
+
+    return {
+      id: link.id as string,
+      linkCode: link.link_code as string,
+      shareUrl: `${window.location.origin}/s/${link.link_code}`,
+      expiresAt,
+      viewCount: (link.view_count as number) || 0,
+      lastViewedAt: link.last_viewed_at ? new Date(link.last_viewed_at as string) : undefined,
+      isRevoked,
+      isValid: !isRevoked && !isExpired,
+      createdAt: new Date(link.created_at as string),
+    };
+  });
 }
 
 export async function revokeShareLink(
-  shortCode: string,
+  _shortCode: string, // Kept for API compatibility, but not used with CRUD approach
   linkCode: string,
-  token?: string
+  token?: string,
+  sessionToken?: string
 ): Promise<void> {
-  const headers: Record<string, string> = {};
+  const headers: Record<string, string> = {
+    'Content-Type': 'application/json',
+  };
   if (token) {
     headers['Authorization'] = `Bearer ${token}`;
   }
+  if (sessionToken) {
+    headers['X-Session-Token'] = sessionToken;
+  }
+
+  // First find the link ID by link_code
+  const params = new URLSearchParams({
+    filter: `link_code.eq.${linkCode}`,
+    limit: '1',
+  });
+
+  const findRes = await fetch(`${API_BASE}/share_links?${params}`, { headers });
+  if (!findRes.ok) {
+    throw new Error('Failed to find share link');
+  }
 
-  const res = await fetch(`${API_BASE}/images/${shortCode}/links/${linkCode}`, {
-    method: 'DELETE',
+  const findData = await findRes.json();
+  if (!findData.data || findData.data.length === 0) {
+    throw new Error('Share link not found');
+  }
+
+  const linkId = findData.data[0].id;
+
+  // Update the link to mark it as revoked
+  const res = await fetch(`${API_BASE}/share_links/${linkId}`, {
+    method: 'PUT',
     headers,
+    body: JSON.stringify({
+      is_revoked: true,
+    }),
   });
 
   if (!res.ok) {
     throw new Error('Failed to revoke share link');
   }
 }
+
+export async function getShareLinkByCode(
+  linkCode: string
+): Promise<ShareLink | null> {
+  const params = new URLSearchParams({
+    filter: `link_code.eq.${linkCode}`,
+    limit: '1',
+  });
+
+  const res = await fetch(`${API_BASE}/share_links?${params}`);
+
+  if (!res.ok) {
+    return null;
+  }
+
+  const data = await res.json();
+  if (!data.data || data.data.length === 0) {
+    return null;
+  }
+
+  const link = data.data[0];
+  const now = new Date();
+  const expiresAt = new Date(link.expires_at as string);
+  const isExpired = expiresAt < now;
+  const isRevoked = link.is_revoked as boolean;
+
+  return {
+    id: link.id as string,
+    linkCode: link.link_code as string,
+    shareUrl: `${window.location.origin}/s/${link.link_code}`,
+    expiresAt,
+    viewCount: (link.view_count as number) || 0,
+    lastViewedAt: link.last_viewed_at ? new Date(link.last_viewed_at as string) : undefined,
+    isRevoked,
+    isValid: !isRevoked && !isExpired,
+    createdAt: new Date(link.created_at as string),
+  };
+}
+
+export async function incrementViewCount(
+  linkCode: string
+): Promise<void> {
+  // Find the link
+  const params = new URLSearchParams({
+    filter: `link_code.eq.${linkCode}`,
+    limit: '1',
+  });
+
+  const findRes = await fetch(`${API_BASE}/share_links?${params}`);
+  if (!findRes.ok) return;
+
+  const findData = await findRes.json();
+  if (!findData.data || findData.data.length === 0) return;
+
+  const link = findData.data[0];
+  const currentCount = (link.view_count as number) || 0;
+
+  // Update view count
+  await fetch(`${API_BASE}/share_links/${link.id}`, {
+    method: 'PUT',
+    headers: {
+      'Content-Type': 'application/json',
+    },
+    body: JSON.stringify({
+      view_count: currentCount + 1,
+      last_viewed_at: new Date().toISOString(),
+    }),
+  });
+}

+ 3 - 1
src/components/AnalyticsDashboard.tsx

@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
 import { useBaaS } from '@picobaas/client/react';
 import type { ImageAnalytics, ViewLog, ShareLink } from '../types';
 import { listShareLinks } from '../api/shareLinks';
+import { getSessionToken } from '../utils/session';
 import LoadingSpinner from './LoadingSpinner';
 
 interface ImageInfo {
@@ -38,10 +39,11 @@ export default function AnalyticsDashboard({ shortCode, onClose }: AnalyticsDash
       };
 
       // Fetch image info, analytics, and share links in parallel
+      const sessionToken = getSessionToken();
       const [imageResponse, analyticsResponse, links] = await Promise.all([
         fetch(`/api/images/${shortCode}`, { headers }),
         fetch(`/api/images/${shortCode}/analytics`, { headers }),
-        listShareLinks(shortCode, token || undefined),
+        listShareLinks(shortCode, token || undefined, sessionToken),
       ]);
 
       // Handle image info

+ 7 - 3
src/components/ShareModal.tsx

@@ -3,6 +3,7 @@ import { useBaaS, useAuth } from '@picobaas/client/react';
 import type { ImageFile, ShareLink } from '../types';
 import { SHARE_LINK_EXPIRY_OPTIONS } from '../types';
 import { createShareLink, listShareLinks, revokeShareLink } from '../api/shareLinks';
+import { getSessionToken } from '../utils/session';
 import LoadingSpinner from './LoadingSpinner';
 
 interface ShareModalProps {
@@ -27,7 +28,8 @@ export default function ShareModal({ isOpen, onClose, image }: ShareModalProps)
     if (!image.shortCode) return;
     setIsLoading(true);
     try {
-      const data = await listShareLinks(image.shortCode, client.accessToken || undefined);
+      const sessionToken = getSessionToken();
+      const data = await listShareLinks(image.shortCode, client.accessToken || undefined, sessionToken);
       setLinks(data);
     } catch (err) {
       setError(err instanceof Error ? err.message : 'Failed to load links');
@@ -47,7 +49,8 @@ export default function ShareModal({ isOpen, onClose, image }: ShareModalProps)
     setIsCreating(true);
     setError(null);
     try {
-      await createShareLink(image.shortCode, expiresIn, client.accessToken || undefined);
+      const sessionToken = getSessionToken();
+      await createShareLink(image.shortCode, expiresIn, client.accessToken || undefined, sessionToken, image.id);
       await loadLinks();
     } catch (err) {
       setError(err instanceof Error ? err.message : 'Failed to create link');
@@ -59,7 +62,8 @@ export default function ShareModal({ isOpen, onClose, image }: ShareModalProps)
   const handleRevokeLink = async (linkCode: string) => {
     if (!image.shortCode) return;
     try {
-      await revokeShareLink(image.shortCode, linkCode, client.accessToken || undefined);
+      const sessionToken = getSessionToken();
+      await revokeShareLink(image.shortCode, linkCode, client.accessToken || undefined, sessionToken);
       await loadLinks();
     } catch (err) {
       setError(err instanceof Error ? err.message : 'Failed to revoke link');