fix: Applied fix for name, the oracle team sharing, sentinel client list visibility

This commit is contained in:
Sagnik
2026-04-19 17:07:12 +05:30
parent 269591a3cc
commit d886e4a669
20 changed files with 940 additions and 109 deletions

4
.gitignore vendored
View File

@@ -171,6 +171,10 @@ docker-compose.override.yml
*.pem
*.mp4
*.zip
.local-dev/
runtime-snapshots/
local-dev-bundles/
scripts/tmp/
models/
comfy_engine/test_outputs/

4
app/dist/index.html vendored
View File

@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Velocity WebOS</title>
<script type="module" crossorigin src="./assets/index-CV1YNwsn.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BDvIhi37.css">
<script type="module" crossorigin src="./assets/index-Bj2Xa_13.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-W2SBxMnB.css">
</head>
<body>
<div id="root"></div>

View File

@@ -21,7 +21,7 @@ import {
getVelocityToken,
isAdminRole,
normalizeVelocityRole,
type VelocityUserProfile,
resolveVelocityFirstName,
} from '@/lib/velocityPlatformClient';
import {
@@ -52,8 +52,8 @@ export const MODULE_ROUTES: Array<{
{ id: 'sentinel', path: '/sentinel', title: 'The Sentinel', component: Sentinel },
{ id: 'inventory', path: '/inventory', title: 'Inventory', component: Inventory },
{ id: 'catalyst', path: '/catalyst', title: 'The Catalyst', component: Catalyst },
{ id: 'settings', path: '/settings', title: 'Settings', component: Settings },
{ id: 'crm', path: '/crm', title: 'CRM', component: CRM },
{ id: 'settings', path: '/settings', title: 'Settings', component: Settings },
{ id: 'admin', path: '/admin', title: 'Admin', component: AdminPage, adminOnly: true },
];
@@ -98,7 +98,7 @@ function MainLayout() {
const currentRoute = availableRoutes.find((r) => r.path === location.pathname);
const pageTitle = currentRoute?.title ?? 'Velocity';
const roleLabel = formatRoleLabel(user?.role);
const userLabel = user?.name?.trim() || user?.id || 'Authenticated User';
const userLabel = user?.name?.trim() || user?.fullName?.trim() || user?.email?.trim() || user?.id || 'Authenticated User';
const initials = userLabel
.split(/\s+/)
.filter(Boolean)
@@ -187,7 +187,13 @@ function MainLayout() {
<span>Settings</span>
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-white/10" />
<DropdownMenuItem className="text-red-400 focus:text-red-400 focus:bg-red-500/10 cursor-pointer" onClick={() => logout()}>
<DropdownMenuItem
className="text-red-400 focus:text-red-400 focus:bg-red-500/10 cursor-pointer"
onClick={() => {
clearVelocityToken();
logout();
}}
>
<LogOut className="mr-2 h-4 w-4" />
<span>Log out</span>
</DropdownMenuItem>
@@ -237,7 +243,7 @@ function MainLayout() {
// ── Root App ──────────────────────────────────────────────────────────────────
function App() {
const { isAuthenticated, login, logout } = useStore();
const { isAuthenticated, login, logout, user } = useStore();
const [authBootstrapped, setAuthBootstrapped] = useState(false);
useEffect(() => {
@@ -257,9 +263,18 @@ function App() {
void getVelocityMe()
.then((me) => {
if (cancelled) return;
const resolvedEmail = me.email?.trim() || user?.email?.trim() || undefined;
const resolvedFullName = me.full_name?.trim() || user?.fullName?.trim() || undefined;
login({
id: me.user_id,
name: resolveVelocityDisplayName(me),
name: resolveVelocityFirstName({
...me,
email: resolvedEmail ?? null,
full_name: resolvedFullName ?? null,
}),
fullName: resolvedFullName,
email: resolvedEmail,
avatar: me.avatar_url?.trim() || user?.avatar?.trim() || undefined,
role: normalizeVelocityRole(me.role),
});
setAuthBootstrapped(true);
@@ -286,9 +301,18 @@ function App() {
void getVelocityMe()
.then((me) => {
if (cancelled) return;
const resolvedEmail = me.email?.trim() || user?.email?.trim() || undefined;
const resolvedFullName = me.full_name?.trim() || user?.fullName?.trim() || undefined;
login({
id: me.user_id,
name: resolveVelocityDisplayName(me),
name: resolveVelocityFirstName({
...me,
email: resolvedEmail ?? null,
full_name: resolvedFullName ?? null,
}),
fullName: resolvedFullName,
email: resolvedEmail,
avatar: me.avatar_url?.trim() || user?.avatar?.trim() || undefined,
role: normalizeVelocityRole(me.role),
});
})
@@ -301,7 +325,7 @@ function App() {
return () => {
cancelled = true;
};
}, [authBootstrapped, isAuthenticated, login, logout]);
}, [authBootstrapped, isAuthenticated, login, logout, user]);
if (!authBootstrapped) {
return (
@@ -363,17 +387,3 @@ function formatRoleLabel(role: string | undefined) {
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
function resolveVelocityDisplayName(profile: VelocityUserProfile) {
const fullName = profile.full_name?.trim();
if (fullName) {
return fullName;
}
const email = profile.email?.trim();
if (email) {
return email;
}
return profile.user_id;
}

View File

@@ -135,10 +135,10 @@ export default function OraclePage() {
// ── Share handler ───────────────────────────────────────────────────────────
const handleShare = useCallback(async (params: { recipientEmail: string; visibility: 'private' | 'team'; message: string; sourceRevision: number }) => {
const handleShare = useCallback(async (params: { recipientUserId: string; visibility: 'private' | 'team'; message: string; sourceRevision: number }) => {
if (!page) return;
await createFork(page.pageId, {
recipientUserId: params.recipientEmail,
recipientUserId: params.recipientUserId,
sourceRevision: params.sourceRevision,
visibility: params.visibility,
message: params.message,

View File

@@ -6,7 +6,7 @@ import {
clearVelocityToken,
loginVelocity,
normalizeVelocityRole,
type VelocityUserProfile,
resolveVelocityFirstName,
} from '@/lib/velocityPlatformClient';
export function LoginScreen() {
@@ -22,9 +22,18 @@ export function LoginScreen() {
setError('');
try {
const me = await loginVelocity(email.trim(), password);
const fallbackEmail = me.email?.trim() || email.trim();
const fallbackFullName = me.full_name?.trim() || undefined;
login({
id: me.user_id,
name: resolveVelocityDisplayName(me),
name: resolveVelocityFirstName({
...me,
email: fallbackEmail || null,
full_name: fallbackFullName || null,
}),
fullName: fallbackFullName,
email: fallbackEmail || undefined,
avatar: me.avatar_url?.trim() || undefined,
role: normalizeVelocityRole(me.role),
});
} catch (err) {
@@ -172,17 +181,3 @@ export function LoginScreen() {
</div>
);
}
function resolveVelocityDisplayName(profile: VelocityUserProfile) {
const fullName = profile.full_name?.trim();
if (fullName) {
return fullName;
}
const email = profile.email?.trim();
if (email) {
return email;
}
return profile.user_id;
}

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useRef, useState, type ChangeEvent } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
User,
@@ -15,13 +15,20 @@ import {
Check,
ChevronDown,
LogOut,
Pencil,
type LucideIcon,
} from 'lucide-react';
import { useStore } from '@/store/useStore';
import { useCurrency, CURRENCY_OPTIONS } from '@/store/useCurrencyStore';
import type { CurrencyCode } from '@/store/useCurrencyStore';
import { API_URL } from '@/lib/api';
import { clearVelocityToken, getVelocityToken, normalizeVelocityRole } from '@/lib/velocityPlatformClient';
import {
clearVelocityToken,
getVelocityToken,
normalizeVelocityRole,
resolveVelocityFirstName,
uploadVelocityAvatar,
} from '@/lib/velocityPlatformClient';
// ── Design tokens (matching inventory glassmorphism) ─────────────────────────
const GLASS = {
@@ -361,35 +368,104 @@ function CompanionSurfacesCard() {
// ── Profile ──────────────────────────────────────────────────────────────────
function ProfileSettings() {
const { user } = useStore();
const initials = user?.name.split(' ').map((n) => n[0]).join('') ?? 'AU';
const { user, login } = useStore();
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [avatarError, setAvatarError] = useState<string | null>(null);
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
const displayName = user?.fullName?.trim() || user?.name?.trim() || user?.email?.trim() || user?.id || 'Authenticated User';
const firstName = resolveVelocityFirstName({
user_id: user?.id ?? '',
full_name: user?.fullName ?? user?.name ?? null,
email: user?.email ?? null,
});
const initials = (user?.fullName || user?.name || user?.email || 'AU')
.split(/[\s@._-]+/)
.filter(Boolean)
.slice(0, 2)
.map((n) => n[0]?.toUpperCase() ?? '')
.join('') || 'AU';
const roleLabel = normalizeVelocityRole(user?.role)
.toLowerCase()
.split('_')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ') || 'Authenticated User';
const handleAvatarUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file || !user) {
return;
}
setAvatarError(null);
setIsUploadingAvatar(true);
try {
const { avatar_url } = await uploadVelocityAvatar(file);
login({
...user,
avatar: avatar_url,
});
} catch (error) {
setAvatarError(error instanceof Error ? error.message : 'Failed to upload profile picture.');
} finally {
setIsUploadingAvatar(false);
if (event.target) {
event.target.value = '';
}
}
};
return (
<GlassCard delay={0.1}>
<SectionHeader icon={User} title="Profile" />
<div className="px-6 pb-6">
<div className="flex items-center gap-4 mb-5 p-4 rounded-xl" style={INNER_SURFACE}>
<div
className="w-14 h-14 rounded-full flex items-center justify-center flex-shrink-0 text-white text-lg font-bold"
style={{ background: 'hsl(var(--accent))', boxShadow: '0 0 20px hsl(var(--accent) / 0.3)' }}
>
{initials}
<div className="relative flex-shrink-0">
{user?.avatar ? (
<img
src={user.avatar}
alt={displayName}
className="w-14 h-14 rounded-full object-cover border border-white/10 shadow-[0_0_20px_rgba(59,130,246,0.2)]"
/>
) : (
<div
className="w-14 h-14 rounded-full flex items-center justify-center text-white text-lg font-bold"
style={{ background: 'hsl(var(--accent))', boxShadow: '0 0 20px hsl(var(--accent) / 0.3)' }}
>
{initials}
</div>
)}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="absolute -right-1 -top-1 w-6 h-6 rounded-full flex items-center justify-center border border-white/10 bg-zinc-900/90 text-zinc-200 hover:text-white hover:bg-zinc-800 transition-colors"
title="Update profile picture"
>
<Pencil className="w-3 h-3" />
</button>
</div>
<div>
<p className="text-white font-semibold">{user?.name}</p>
<div className="min-w-0 flex-1">
<p className="text-white font-semibold truncate">{displayName}</p>
<p className="text-xs mt-0.5" style={{ color: 'hsl(var(--muted-fg))' }}>
{roleLabel}
</p>
{isUploadingAvatar && (
<p className="mt-3 text-xs text-zinc-400">Uploading profile picture...</p>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/jpg"
className="hidden"
onChange={handleAvatarUpload}
/>
</div>
{avatarError && (
<p className="mb-4 text-xs text-red-400">{avatarError}</p>
)}
<div className="space-y-0 -mx-6">
<SettingsRow label="Authenticated Name" description="Resolved from the active Velocity session">
<span className="text-sm text-white">{user?.name ?? 'Unavailable'}</span>
<span className="text-sm text-white">{firstName || 'Unavailable'}</span>
</SettingsRow>
<SettingsRow label="User ID" description="Backend principal identifier">
<span className="text-sm font-mono text-white">{user?.id ?? 'Unavailable'}</span>

View File

@@ -7,6 +7,15 @@ export interface VelocityUserProfile {
role: string;
full_name?: string | null;
email?: string | null;
avatar_url?: string | null;
}
export interface VelocityActiveUser {
user_id: string;
role: string;
full_name?: string | null;
email?: string | null;
avatar_url?: string | null;
}
export interface VelocityLoginResponse {
@@ -189,6 +198,39 @@ export function normalizeVelocityRole(role: string | null | undefined): string {
return (role ?? '').trim().toUpperCase();
}
export function resolveVelocityFullName(profile: Pick<VelocityUserProfile, 'full_name' | 'email' | 'user_id'>): string {
const fullName = profile.full_name?.trim();
if (fullName) {
return fullName;
}
const email = profile.email?.trim();
if (email) {
return email;
}
return profile.user_id;
}
export function resolveVelocityFirstName(profile: Pick<VelocityUserProfile, 'full_name' | 'email' | 'user_id'>): string {
const fullName = profile.full_name?.trim();
if (fullName) {
return fullName.split(/\s+/)[0] ?? fullName;
}
const email = profile.email?.trim();
if (email) {
const local = email.split('@')[0]?.trim() ?? '';
if (local) {
const normalized = local.replace(/[._-]+/g, ' ').trim();
const firstToken = normalized.split(/\s+/)[0] ?? normalized;
return firstToken.charAt(0).toUpperCase() + firstToken.slice(1);
}
}
return profile.user_id;
}
export function isAdminRole(role: string | null | undefined): boolean {
const normalized = normalizeVelocityRole(role);
return normalized === 'ADMIN' || normalized === 'SUPERADMIN';
@@ -209,6 +251,36 @@ export async function getVelocityMe(): Promise<VelocityUserProfile> {
});
}
export async function listVelocityUsers(): Promise<VelocityActiveUser[]> {
return platformFetch<VelocityActiveUser[]>('/api/auth/users', {
method: 'GET',
});
}
export async function uploadVelocityAvatar(file: File): Promise<{ avatar_url: string }> {
const form = new FormData();
form.append('file', file);
const response = await fetch(`${API_URL}/api/auth/profile/avatar`, {
method: 'POST',
headers: buildHeaders(undefined, false),
body: form,
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(
typeof body?.detail === 'string'
? body.detail
: typeof body?.message === 'string'
? body.message
: `Request failed: ${response.status}`,
);
}
return response.json() as Promise<{ avatar_url: string }>;
}
export async function getAdminHealth(): Promise<AdminHealthSnapshot> {
return platformFetch<AdminHealthSnapshot>('/api/admin-surface/health');
}

View File

@@ -1,51 +1,99 @@
/**
* ShareModal — Fork-based sharing workflow.
* Explains the direct_fork_only semantics (recipient gets an editable copy,
* not live edit access to owner's canvas).
*/
import { useState, useEffect } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Share2, GitFork, Lock, Users, MessageSquare, ChevronDown } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { CanvasPage } from '../types/canvas';
import { listVelocityUsers, type VelocityActiveUser } from '@/lib/velocityPlatformClient';
interface ShareModalProps {
page: CanvasPage | null;
isOpen: boolean;
onClose: () => void;
onShare: (params: {
recipientEmail: string;
recipientUserId: string;
visibility: 'private' | 'team';
message: string;
sourceRevision: number;
}) => Promise<void>;
}
const TEAM_MEMBERS = [
{ id: 'u2', name: 'Elena Rostova', email: 'elena@binghatti.ae', avatar: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=80&q=80', role: 'Senior Broker' },
{ id: 'u3', name: 'Priya Sharma', email: 'priya@binghatti.ae', avatar: 'https://images.unsplash.com/photo-1554151228-14d9def656e4?auto=format&fit=crop&w=80&q=80', role: 'Senior Broker' },
{ id: 'u4', name: 'Carlos Mendez', email: 'carlos@binghatti.ae', avatar: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?auto=format&fit=crop&w=80&q=80', role: 'Broker' },
{ id: 'u5', name: 'Ravi Kapoor', email: 'ravi@binghatti.ae', avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&w=80&q=80', role: 'Broker' },
];
function getDisplayName(member: VelocityActiveUser): string {
return member.full_name?.trim() || member.email?.trim() || member.user_id;
}
function getRoleLabel(member: VelocityActiveUser): string {
return member.role
.toLowerCase()
.split('_')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
function getInitials(member: VelocityActiveUser): string {
const basis = getDisplayName(member);
return basis
.split(/[\s@._-]+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part.charAt(0).toUpperCase())
.join('') || 'U';
}
export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const [recipient, setRecipient] = useState<{ id: string; name: string; email: string } | null>(null);
const [teamMembers, setTeamMembers] = useState<VelocityActiveUser[]>([]);
const [loadingMembers, setLoadingMembers] = useState(false);
const [membersError, setMembersError] = useState<string | null>(null);
const [recipient, setRecipient] = useState<VelocityActiveUser | null>(null);
const [visibility, setVisibility] = useState<'private' | 'team'>('private');
const [message, setMessage] = useState('');
const [submitting, setSubmitting] = useState(false);
const [success, setSuccess] = useState(false);
const [memberDropOpen, setMemberDropOpen] = useState(false);
useEffect(() => setMounted(true), []);
useEffect(() => {
if (!isOpen) {
setMemberDropOpen(false);
return;
}
let cancelled = false;
setLoadingMembers(true);
setMembersError(null);
void listVelocityUsers()
.then((users) => {
if (cancelled) return;
setTeamMembers(users);
})
.catch((error) => {
if (cancelled) return;
setMembersError(error instanceof Error ? error.message : 'Failed to load team members.');
setTeamMembers([]);
})
.finally(() => {
if (!cancelled) setLoadingMembers(false);
});
return () => {
cancelled = true;
};
}, [isOpen]);
const selectedRecipientLabel = useMemo(
() => (recipient ? getDisplayName(recipient) : 'Select verified teammate...'),
[recipient],
);
const handleShare = async () => {
if (!recipient || !page) return;
setSubmitting(true);
try {
await onShare({
recipientEmail: recipient.email,
recipientUserId: recipient.user_id,
visibility,
message,
sourceRevision: page.headRevision,
@@ -56,9 +104,9 @@ export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps)
onClose();
setRecipient(null);
setMessage('');
}, 2000);
}, 1800);
} catch {
// stay open on error
// keep modal open and let caller surface the error upstream
} finally {
setSubmitting(false);
}
@@ -68,7 +116,6 @@ export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps)
<AnimatePresence>
{isOpen && (
<>
{/* Backdrop */}
<motion.div
key="share-backdrop"
className="fixed inset-0 z-40"
@@ -95,7 +142,6 @@ export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps)
boxShadow: '0 24px 80px rgba(0,0,0,0.8)',
}}
>
{/* Header */}
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-xl bg-blue-500/15 border border-blue-500/25 flex items-center justify-center">
@@ -113,16 +159,14 @@ export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps)
</button>
</div>
{/* Fork explanation */}
<div
className="flex items-start gap-3 p-3 rounded-xl mb-5"
style={{ background: 'rgba(59,130,246,0.07)', border: '1px solid rgba(59,130,246,0.18)' }}
>
<GitFork className="w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5" />
<p className="text-xs text-zinc-300 leading-relaxed">
The recipient gets a <span className="text-blue-300 font-medium">fork</span> an editable copy
of this canvas at revision {page?.headRevision}. They can build on it and open a merge
request to propose their changes back.
The recipient gets a <span className="text-blue-300 font-medium">fork</span> of this canvas at the selected revision.
They can edit their copy and later open a merge request back into the source branch.
</p>
</div>
@@ -131,17 +175,16 @@ export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps)
<div className="w-12 h-12 rounded-full bg-green-500/15 border border-green-500/30 flex items-center justify-center">
<Share2 className="w-6 h-6 text-green-400" />
</div>
<p className="text-sm text-zinc-200 font-medium">Fork created successfully!</p>
<p className="text-xs text-zinc-500">{recipient?.name} can now access their copy.</p>
<p className="text-sm text-zinc-200 font-medium">Fork created successfully.</p>
<p className="text-xs text-zinc-500">{recipient ? getDisplayName(recipient) : 'Recipient'} can access the shared copy.</p>
</div>
) : (
<div className="space-y-4">
{/* Recipient picker */}
<div>
<label className="text-xs font-medium text-zinc-400 mb-1.5 block">Recipient</label>
<div className="relative">
<button
onClick={() => setMemberDropOpen((p) => !p)}
onClick={() => setMemberDropOpen((prev) => !prev)}
className="w-full flex items-center justify-between px-3 py-2.5 rounded-xl text-sm"
style={{
background: 'rgba(255,255,255,0.04)',
@@ -151,10 +194,14 @@ export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps)
>
<div className="flex items-center gap-2">
<Users className="w-3.5 h-3.5 text-zinc-500" />
<span>{recipient?.name ?? 'Select team member…'}</span>
<span>{selectedRecipientLabel}</span>
</div>
<ChevronDown className="w-3.5 h-3.5 text-zinc-600" style={{ transform: memberDropOpen ? 'rotate(180deg)' : 'none' }} />
<ChevronDown
className="w-3.5 h-3.5 text-zinc-600"
style={{ transform: memberDropOpen ? 'rotate(180deg)' : 'none' }}
/>
</button>
<AnimatePresence>
{memberDropOpen && (
<motion.div
@@ -164,16 +211,40 @@ export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps)
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
>
{TEAM_MEMBERS.map((m) => (
{loadingMembers && (
<div className="px-3 py-3 text-xs text-zinc-500">Loading verified accounts...</div>
)}
{!loadingMembers && membersError && (
<div className="px-3 py-3 text-xs text-red-400">{membersError}</div>
)}
{!loadingMembers && !membersError && teamMembers.length === 0 && (
<div className="px-3 py-3 text-xs text-zinc-500">No verified users available.</div>
)}
{!loadingMembers && !membersError && teamMembers.map((member) => (
<button
key={m.id}
key={member.user_id}
className="w-full flex items-center gap-3 px-3 py-2.5 hover:bg-white/5 transition-colors text-left"
onClick={() => { setRecipient(m); setMemberDropOpen(false); }}
onClick={() => {
setRecipient(member);
setMemberDropOpen(false);
}}
>
<img src={m.avatar} className="w-7 h-7 rounded-full" alt={m.name} />
<div>
<p className="text-sm text-zinc-200">{m.name}</p>
<p className="text-[10px] text-zinc-500">{m.role}</p>
{member.avatar_url ? (
<img
src={member.avatar_url}
alt={getDisplayName(member)}
className="w-7 h-7 rounded-full object-cover"
/>
) : (
<div className="w-7 h-7 rounded-full bg-blue-500/15 border border-blue-500/25 text-[10px] font-semibold text-blue-300 flex items-center justify-center">
{getInitials(member)}
</div>
)}
<div className="min-w-0">
<p className="text-sm text-zinc-200 truncate">{getDisplayName(member)}</p>
<p className="text-[10px] text-zinc-500 truncate">
{member.email || member.user_id} · {getRoleLabel(member)}
</p>
</div>
</button>
))}
@@ -183,7 +254,6 @@ export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps)
</div>
</div>
{/* Visibility */}
<div>
<label className="text-xs font-medium text-zinc-400 mb-1.5 block">Fork visibility</label>
<div className="grid grid-cols-2 gap-2">
@@ -210,7 +280,6 @@ export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps)
</div>
</div>
{/* Message */}
<div>
<label className="text-xs font-medium text-zinc-400 mb-1.5 flex items-center gap-1.5">
<MessageSquare className="w-3 h-3" />
@@ -219,7 +288,7 @@ export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps)
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Add context for the recipient"
placeholder="Add context for the recipient..."
rows={3}
className="w-full px-3 py-2.5 text-sm rounded-xl resize-none focus:outline-none"
style={{
@@ -230,7 +299,6 @@ export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps)
/>
</div>
{/* Actions */}
<div className="flex gap-2 pt-1">
<Button
variant="outline"
@@ -244,7 +312,7 @@ export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps)
disabled={!recipient || submitting}
className="flex-1 bg-blue-600 hover:bg-blue-500 text-white"
>
{submitting ? 'Creating fork' : 'Share (Create Fork)'}
{submitting ? 'Creating fork...' : 'Share (Create Fork)'}
</Button>
</div>
</div>

View File

@@ -50,11 +50,11 @@ export function useOraclePage(pageId: string | null): OraclePageState {
}
const disconnect = connectPageSocket(pageId, {
onMessage: (msg: OracleWSMessage) => handleWSMessage(msg),
onReconnect: () => void load(),
onReconnect: () => undefined,
onOpen: () => setIsConnected(true),
onClose: () => setIsConnected(false),
});
disconnectRef.current = disconnect;
setIsConnected(true);
return () => {
disconnect();
disconnectRef.current = null;

View File

@@ -167,7 +167,8 @@ export function connectPageSocket(
pageId: string,
handlers: {
onMessage: (msg: OracleWSMessage) => void;
onReconnect: () => void;
onReconnect?: () => void;
onOpen?: () => void;
onClose: () => void;
},
): () => void {
@@ -184,6 +185,10 @@ export function connectPageSocket(
function connect() {
ws = new WebSocket(`${wsBase}/ws/oracle/canvas/${pageId}`);
ws.onopen = () => {
handlers.onOpen?.();
};
ws.onmessage = (event) => {
try {
handlers.onMessage(JSON.parse(event.data as string) as OracleWSMessage);
@@ -196,7 +201,7 @@ export function connectPageSocket(
handlers.onClose();
if (!stopped) {
retryTimeout = setTimeout(() => {
handlers.onReconnect();
handlers.onReconnect?.();
connect();
}, 3000);
}

View File

@@ -14,6 +14,8 @@ export interface NavItem {
export interface User {
id: string;
name: string;
fullName?: string;
email?: string;
avatar?: string;
role: string;
}

View File

@@ -3,7 +3,7 @@ import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"
import { inspectAttr } from 'kimi-plugin-inspect-react'
const backendProxyTarget = process.env.VITE_BACKEND_PROXY_TARGET?.trim() || "https://api.desineuron.in"
const backendProxyTarget = process.env.VITE_BACKEND_PROXY_TARGET?.trim() || "https://velocity.desineuron.in"
// https://vite.dev/config/
export default defineConfig({
@@ -37,6 +37,12 @@ export default defineConfig({
changeOrigin: true,
secure: false,
},
"/ws": {
target: backendProxyTarget,
changeOrigin: true,
secure: false,
ws: true,
},
},
},
});

View File

@@ -34,13 +34,19 @@ ROLE_HIERARCHY = {
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def _truncate_bcrypt_input(value: str) -> str:
raw = value.encode("utf-8")
if len(raw) <= 72:
return value
return raw[:72].decode("utf-8", errors="ignore")
def hash_password(plain: str) -> str:
return pwd_context.hash(plain)
return pwd_context.hash(_truncate_bcrypt_input(plain))
def verify_password(plain: str, hashed: str) -> bool:
# Truncate to 72 bytes to prevent bcrypt 500 errors
return pwd_context.verify(plain[:72], hashed)
return pwd_context.verify(_truncate_bcrypt_input(plain), hashed)
# ── JWT helpers ───────────────────────────────────────────────────────────────

View File

@@ -11,15 +11,50 @@ import os
import json
import asyncio
import logging
import re
from contextlib import asynccontextmanager
from datetime import UTC, datetime
from pathlib import Path
from typing import Set
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, HTTPException, status
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, HTTPException, status, UploadFile, File
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from dotenv import load_dotenv
def _load_velocity_env() -> None:
repo_root = Path(__file__).resolve().parent.parent
backend_root = repo_root / "backend"
explicit_env = os.getenv("VELOCITY_ENV_FILE", "").strip()
candidate_paths = []
if explicit_env:
candidate_paths.append(Path(explicit_env))
candidate_paths.extend(
[
backend_root / ".env",
repo_root / ".env",
]
)
loaded_any = False
seen: set[Path] = set()
for candidate in candidate_paths:
resolved = candidate.resolve()
if resolved in seen or not candidate.exists():
continue
load_dotenv(candidate, override=not loaded_any)
loaded_any = True
seen.add(resolved)
if not loaded_any:
load_dotenv()
_load_velocity_env()
from backend.api.routes_catalyst import router as catalyst_router
from backend.api.routes_crm import crm_router, analytics_router
from backend.api.routes_oracle import router as oracle_helper_router
@@ -39,8 +74,6 @@ from backend.routers.videos import router as videos_router
from backend.routers.vault import router as vault_router
from backend.routers.sentinel import router as sentinel_router, broadcast_sentinel_event
load_dotenv()
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("velocity.main")
@@ -91,6 +124,11 @@ ASSET_DIR = os.getenv("VELOCITY_ASSET_DIR", "/opt/dlami/nvme/assets")
if os.path.isdir(ASSET_DIR):
app.mount("/assets", StaticFiles(directory=ASSET_DIR), name="assets")
def _sanitize_filename(value: str) -> str:
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", value).strip("._")
return cleaned or "upload"
# ── Routers ───────────────────────────────────────────────────────────────────
app.include_router(catalyst_router, prefix="/api/catalyst", tags=["Catalyst"])
@@ -160,7 +198,7 @@ async def me(user: UserPrincipal = Depends(get_current_user)):
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT full_name, email
SELECT full_name, email, avatar_url
FROM users_and_roles
WHERE id = $1::uuid
""",
@@ -172,9 +210,85 @@ async def me(user: UserPrincipal = Depends(get_current_user)):
"role": user.role,
"full_name": row["full_name"] if row else None,
"email": row["email"] if row else None,
"avatar_url": row["avatar_url"] if row else None,
}
@app.get("/api/auth/users", tags=["Auth"])
async def list_auth_users(_: UserPrincipal = Depends(get_current_user)):
pool = app.state.db_pool
if pool is None:
raise HTTPException(status_code=503, detail="Database unavailable.")
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT
id::text AS user_id,
role,
full_name,
email,
avatar_url
FROM users_and_roles
WHERE is_active = TRUE
ORDER BY
COALESCE(NULLIF(full_name, ''), email, id::text) ASC
"""
)
return [
{
"user_id": row["user_id"],
"role": row["role"],
"full_name": row["full_name"],
"email": row["email"],
"avatar_url": row["avatar_url"],
}
for row in rows
]
@app.post("/api/auth/profile/avatar", tags=["Auth"])
async def upload_profile_avatar(
file: UploadFile = File(...),
user: UserPrincipal = Depends(get_current_user),
):
pool = app.state.db_pool
if pool is None:
raise HTTPException(status_code=503, detail="Database unavailable.")
allowed = {"image/png", "image/jpeg", "image/jpg", "image/webp"}
if file.content_type not in allowed:
raise HTTPException(status_code=400, detail="Unsupported avatar format.")
extension = Path(file.filename or "avatar.png").suffix.lower() or ".png"
if extension not in {".png", ".jpg", ".jpeg", ".webp"}:
extension = ".png"
avatar_dir = Path(ASSET_DIR) / "profile_avatars"
avatar_dir.mkdir(parents=True, exist_ok=True)
filename = f"{user.user_id}_{_sanitize_filename(Path(file.filename or 'avatar').stem)}_{int(datetime.now(UTC).timestamp())}{extension}"
destination = avatar_dir / filename
contents = await file.read()
destination.write_bytes(contents)
avatar_url = f"/assets/profile_avatars/{filename}"
async with pool.acquire() as conn:
await conn.execute(
"""
UPDATE users_and_roles
SET avatar_url = $2
WHERE id = $1::uuid
""",
user.user_id,
avatar_url,
)
return {"avatar_url": avatar_url}
# ── Catalyst WebSocket (preserved from v1) ────────────────────────────────────
class _CatalystManager:

143
docs/LOCAL_DEV_SNAPSHOT.md Normal file
View File

@@ -0,0 +1,143 @@
# Velocity Local Dev Snapshot
This document defines the correct way to work against a local copy of the current Velocity backend state without polluting Git and without guessing whether a backend-only change will work in production.
## Why This Exists
Project Velocity now depends on live PostgreSQL-backed surfaces and runtime state that cannot be validated reliably from frontend-only local development.
Examples:
- operator identity and profile hydration
- CRM-backed Sentinel client selection
- Oracle Canvas data access and sharing
- verified team-account lists for share flows
The correct solution is a repeatable local snapshot workflow.
## Design Rule
Local runtime copies must never be committed.
Everything produced by this workflow lives under gitignored paths:
- `.local-dev/`
- `runtime-snapshots/`
- `local-dev-bundles/`
## Bundle Shape
The Linux-origin export bundle contains:
- `backend.env.export`
- `velocity.dump`
- `restore.instructions.txt`
This is intentionally minimal. It gives local developers what they need to stand up a local database-backed verification surface without pushing runtime state into the repository.
## Export From Linux Box
Run this on the Linux-origin host:
```bash
cd /opt/desineuron-velocity-site/repo
bash scripts/export_velocity_local_bundle.sh
```
This creates a tarball in `/tmp` named like:
```text
/tmp/velocity-local-bundle-YYYYmmdd-HHMMSS.tar.gz
```
## Import On Windows Dev Machine
1. Copy the tarball to the Windows machine.
2. Extract it under:
```text
Project_Velocity/.local-dev/source-bundles/<bundle-name>/
```
3. Run:
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\import_velocity_local_bundle.ps1
```
This will:
- copy the exported env snapshot into `.local-dev/backend/.env.local`
- copy the PostgreSQL dump into `.local-dev/db/velocity.dump`
- generate a local Docker Compose file for PostgreSQL
- generate a restore script
## Restore Local Database
After import, run:
```powershell
powershell -ExecutionPolicy Bypass -File .\.local-dev\db\restore_local_snapshot.ps1
```
This restores the dump into a local PostgreSQL container on:
- host: `127.0.0.1`
- port: `54329`
- db: `velocity_local`
- user: `velocity_local`
- password: `velocity_local`
## Local Backend Wiring
The backend expects these env keys:
- `VELOCITY_DB_HOST`
- `VELOCITY_DB_PORT`
- `VELOCITY_DB_NAME`
- `VELOCITY_DB_USER`
- `VELOCITY_DB_PASSWORD`
- `DATABASE_URL`
- `CORS_ORIGINS`
- `VELOCITY_ASSET_DIR`
- `JWT_SECRET_KEY`
The exported env snapshot provides the production-shaped variable names. For local verification, point the DB values to the local PostgreSQL container rather than the production database.
## Create A Zip For Sayan And Sourik
Once `.local-dev/` is hydrated locally, run:
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\package_velocity_local_bundle.ps1
```
This creates a gitignored zip under:
```text
Project_Velocity/local-dev-bundles/
```
That zip is the correct handoff artifact for teammate local verification.
## What This Solves
This workflow allows local verification of:
- real account-backed login flows
- profile name/avatar hydration
- share-recipient lookup
- Sentinel CRM client selection
- Oracle-backed DB reads
## What It Does Not Solve By Itself
This does not automatically fix backend bugs.
If Oracle still returns `500` locally after restoring the snapshot, that is now a real reproducible backend issue rather than a blind production-only guess.
## Current Constraint
The current machine cannot SSH into the Linux box because the host is configured for publickey auth and this workstation is not authorized yet.
That means the export step must currently be triggered from a machine or shell that already has valid SSH/key access to the Linux-origin host.

View File

@@ -0,0 +1,122 @@
#!/usr/bin/env bash
set -euo pipefail
# Run this on the Linux origin host that currently serves velocity.desineuron.in.
# It exports a local-development bundle containing:
# - backend env snapshot (non-shell format)
# - PostgreSQL custom-format dump
# - app build metadata
# - optional asset snapshot manifest
#
# Output:
# /tmp/velocity-local-bundle-YYYYmmdd-HHMMSS.tar.gz
REPO_ROOT="${REPO_ROOT:-/opt/desineuron-velocity-site/repo}"
BACKEND_ENV_PATH="${BACKEND_ENV_PATH:-$REPO_ROOT/backend/.env}"
OUTPUT_ROOT="${OUTPUT_ROOT:-/tmp}"
DB_CONTAINER_NAME="${DB_CONTAINER_NAME:-desineuron-ops-db}"
STAMP="$(date +%Y%m%d-%H%M%S)"
WORKDIR="$OUTPUT_ROOT/velocity-local-bundle-$STAMP"
ARCHIVE="$OUTPUT_ROOT/velocity-local-bundle-$STAMP.tar.gz"
if [[ ! -f "$BACKEND_ENV_PATH" ]]; then
echo "Missing backend env file: $BACKEND_ENV_PATH" >&2
exit 1
fi
mkdir -p "$WORKDIR"
cp "$BACKEND_ENV_PATH" "$WORKDIR/backend.env.raw"
python3 - "$WORKDIR/backend.env.raw" "$WORKDIR/backend.env.export" <<'PY'
import pathlib
import sys
src = pathlib.Path(sys.argv[1])
dst = pathlib.Path(sys.argv[2])
allowed = {
"VELOCITY_DB_HOST",
"VELOCITY_DB_PORT",
"VELOCITY_DB_NAME",
"VELOCITY_DB_USER",
"VELOCITY_DB_PASSWORD",
"DATABASE_URL",
"CORS_ORIGINS",
"VELOCITY_ASSET_DIR",
"JWT_SECRET_KEY",
"JWT_ALGORITHM",
"JWT_EXP_MINUTES",
}
lines = []
for raw_line in src.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
if key in allowed:
lines.append(f"{key}={value.strip()}")
dst.write_text("\n".join(lines) + "\n", encoding="utf-8")
PY
set -a
source "$BACKEND_ENV_PATH"
set +a
dump_with_local_pg_dump() {
PGPASSWORD="${VELOCITY_DB_PASSWORD}" pg_dump \
-h "${VELOCITY_DB_HOST}" \
-p "${VELOCITY_DB_PORT}" \
-U "${VELOCITY_DB_USER}" \
-d "${VELOCITY_DB_NAME}" \
-Fc \
-f "$WORKDIR/velocity.dump"
}
dump_with_docker_exec() {
if ! command -v docker >/dev/null 2>&1; then
return 1
fi
sudo -n docker exec \
-e PGPASSWORD="${VELOCITY_DB_PASSWORD}" \
"$DB_CONTAINER_NAME" \
pg_dump \
-U "${VELOCITY_DB_USER}" \
-d "${VELOCITY_DB_NAME}" \
-Fc \
> "$WORKDIR/velocity.dump"
}
if command -v pg_dump >/dev/null 2>&1; then
dump_with_local_pg_dump
elif dump_with_docker_exec; then
:
else
echo "Neither host pg_dump nor docker-exec pg_dump is available." >&2
exit 1
fi
cat > "$WORKDIR/README.txt" <<EOF
Velocity local bundle
Created: $(date --iso-8601=seconds)
Repo root: $REPO_ROOT
Bundle contents:
- backend.env.export
- velocity.dump
- restore.instructions.txt
EOF
cat > "$WORKDIR/restore.instructions.txt" <<EOF
1. Copy this archive to the Windows development machine.
2. Extract it under Project_Velocity/.local-dev/source-bundles/<bundle-name>.
3. Run scripts/import_velocity_local_bundle.ps1 from the repo root.
EOF
tar -C "$OUTPUT_ROOT" -czf "$ARCHIVE" "$(basename "$WORKDIR")"
rm -rf "$WORKDIR"
echo "Created bundle: $ARCHIVE"

View File

@@ -0,0 +1,140 @@
param(
[string]$BundleRoot = "",
[string]$RepoRoot = "F:\Workin In Progress\DESINEURON\GITLAB\Project_Velocity"
)
$ErrorActionPreference = "Stop"
if (-not $BundleRoot) {
$candidate = Join-Path $RepoRoot ".local-dev\source-bundles"
if (-not (Test-Path $candidate)) {
throw "Bundle root not provided and no source bundles found at $candidate"
}
$latest = Get-ChildItem -Path $candidate -Directory | Sort-Object LastWriteTime -Descending | Select-Object -First 1
if (-not $latest) {
throw "No extracted bundle directories found under $candidate"
}
$BundleRoot = $latest.FullName
}
$envExport = Join-Path $BundleRoot "backend.env.export"
$dumpFile = Join-Path $BundleRoot "velocity.dump"
if (-not (Test-Path $envExport)) {
throw "Missing backend.env.export in $BundleRoot"
}
if (-not (Test-Path $dumpFile)) {
throw "Missing velocity.dump in $BundleRoot"
}
$localRoot = Join-Path $RepoRoot ".local-dev"
$backendRoot = Join-Path $localRoot "backend"
$dbRoot = Join-Path $localRoot "db"
$assetsRoot = Join-Path $localRoot "assets"
New-Item -ItemType Directory -Force -Path $localRoot, $backendRoot, $dbRoot, $assetsRoot | Out-Null
$targetEnv = Join-Path $backendRoot ".env.local"
$restoreScript = Join-Path $dbRoot "restore_local_snapshot.ps1"
$composeFile = Join-Path $dbRoot "docker-compose.local-db.yml"
$copiedDump = Join-Path $dbRoot "velocity.dump"
Copy-Item $dumpFile $copiedDump -Force
$rawEnv = @{}
Get-Content $envExport | ForEach-Object {
if ($_ -match '^\s*#' -or $_ -notmatch '=') {
return
}
$key, $value = $_ -split '=', 2
$rawEnv[$key.Trim()] = $value.Trim()
}
$localEnvLines = [System.Collections.Generic.List[string]]::new()
$localEnvLines.Add("VELOCITY_DB_HOST=127.0.0.1")
$localEnvLines.Add("VELOCITY_DB_PORT=54329")
$localEnvLines.Add("VELOCITY_DB_NAME=velocity_local")
$localEnvLines.Add("VELOCITY_DB_USER=velocity_local")
$localEnvLines.Add("VELOCITY_DB_PASSWORD=velocity_local")
$localEnvLines.Add("DATABASE_URL=postgresql://velocity_local:velocity_local@127.0.0.1:54329/velocity_local")
$localEnvLines.Add("CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173,http://127.0.0.1:3000,http://localhost:3000")
if ($rawEnv.ContainsKey("VELOCITY_JWT_SECRET")) {
$localEnvLines.Add("VELOCITY_JWT_SECRET=$($rawEnv['VELOCITY_JWT_SECRET'])")
}
if ($rawEnv.ContainsKey("JWT_SECRET_KEY")) {
$localEnvLines.Add("JWT_SECRET_KEY=$($rawEnv['JWT_SECRET_KEY'])")
}
if ($rawEnv.ContainsKey("JWT_ALGORITHM")) {
$localEnvLines.Add("JWT_ALGORITHM=$($rawEnv['JWT_ALGORITHM'])")
}
if ($rawEnv.ContainsKey("JWT_EXP_MINUTES")) {
$localEnvLines.Add("JWT_EXP_MINUTES=$($rawEnv['JWT_EXP_MINUTES'])")
}
$localAssetsRoot = Join-Path $localRoot "assets"
$localEnvLines.Add("VELOCITY_ASSET_DIR=$localAssetsRoot")
Set-Content -Path $targetEnv -Value ($localEnvLines -join "`r`n") -Encoding UTF8
$compose = @"
services:
velocity-postgres-local:
image: postgres:16
container_name: velocity-postgres-local
restart: unless-stopped
environment:
POSTGRES_DB: velocity_local
POSTGRES_USER: velocity_local
POSTGRES_PASSWORD: velocity_local
ports:
- "54329:5432"
volumes:
- velocity_postgres_local_data:/var/lib/postgresql/data
volumes:
velocity_postgres_local_data:
"@
Set-Content -Path $composeFile -Value $compose -Encoding UTF8
$restoreTemplate = @'
param(
[string]$RepoRoot = "__REPO_ROOT__"
)
$ErrorActionPreference = "Stop"
$DbRoot = Join-Path $RepoRoot ".local-dev\db"
$Compose = Join-Path $DbRoot "docker-compose.local-db.yml"
$Dump = Join-Path $DbRoot "velocity.dump"
docker compose -f $Compose up -d
Start-Sleep -Seconds 4
docker exec -i velocity-postgres-local dropdb --if-exists -U velocity_local velocity_local
docker exec -i velocity-postgres-local createdb -U velocity_local velocity_local
docker cp $Dump velocity-postgres-local:/tmp/velocity.dump
docker exec -i velocity-postgres-local pg_restore -U velocity_local -d velocity_local --clean --if-exists --no-owner --no-privileges /tmp/velocity.dump
Write-Host "Local PostgreSQL restore complete."
Write-Host "Use these local DB env values:"
Write-Host "VELOCITY_DB_HOST=127.0.0.1"
Write-Host "VELOCITY_DB_PORT=54329"
Write-Host "VELOCITY_DB_NAME=velocity_local"
Write-Host "VELOCITY_DB_USER=velocity_local"
Write-Host "VELOCITY_DB_PASSWORD=velocity_local"
'@
$restore = $restoreTemplate.Replace("__REPO_ROOT__", $RepoRoot)
Set-Content -Path $restoreScript -Value $restore -Encoding UTF8
Write-Host "Imported local bundle from $BundleRoot"
Write-Host "Backend env snapshot: $targetEnv"
Write-Host "DB dump copied to: $copiedDump"
Write-Host "Run this next:"
Write-Host "powershell -ExecutionPolicy Bypass -File `"$restoreScript`""

View File

@@ -0,0 +1,25 @@
param(
[string]$RepoRoot = "F:\Workin In Progress\DESINEURON\GITLAB\Project_Velocity",
[string]$OutputDir = "F:\Workin In Progress\DESINEURON\GITLAB\Project_Velocity\local-dev-bundles"
)
$ErrorActionPreference = "Stop"
$localRoot = Join-Path $RepoRoot ".local-dev"
if (-not (Test-Path $localRoot)) {
throw "Missing .local-dev folder. Import a local bundle first."
}
New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null
$stamp = Get-Date -Format "yyyyMMdd-HHmmss"
$zipPath = Join-Path $OutputDir "project-velocity-local-dev-$stamp.zip"
if (Test-Path $zipPath) {
Remove-Item $zipPath -Force
}
Compress-Archive -Path (Join-Path $localRoot "*") -DestinationPath $zipPath -Force
Write-Host "Created local dev zip: $zipPath"
Write-Host "This zip is gitignored and can be handed to Sayan and Sourik for local verification."

View File

@@ -0,0 +1,28 @@
param(
[string]$RepoRoot = "F:\Workin In Progress\DESINEURON\GITLAB\Project_Velocity",
[int]$Port = 8001
)
$ErrorActionPreference = "Stop"
$env:VELOCITY_ENV_FILE = Join-Path $RepoRoot ".local-dev\backend\.env.local"
if (-not (Test-Path $env:VELOCITY_ENV_FILE)) {
throw "Missing local backend env file at $($env:VELOCITY_ENV_FILE). Import a local bundle first."
}
$pythonExe = Join-Path $RepoRoot ".venv\Scripts\python.exe"
if (-not (Test-Path $pythonExe)) {
$pythonExe = Join-Path $RepoRoot "venv\Scripts\python.exe"
}
if (-not (Test-Path $pythonExe)) {
$pythonExe = "python"
}
Push-Location $RepoRoot
try {
& $pythonExe -m uvicorn backend.main:app --reload --host 127.0.0.1 --port $Port
}
finally {
Pop-Location
}

View File

@@ -0,0 +1,15 @@
param(
[string]$RepoRoot = "F:\Workin In Progress\DESINEURON\GITLAB\Project_Velocity"
)
$ErrorActionPreference = "Stop"
$env:VITE_BACKEND_PROXY_TARGET = "http://127.0.0.1:8001"
Push-Location (Join-Path $RepoRoot "app")
try {
npm run dev -- --host 127.0.0.1 --port 5173
}
finally {
Pop-Location
}