-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsync.js
More file actions
141 lines (127 loc) · 5.49 KB
/
Copy pathsync.js
File metadata and controls
141 lines (127 loc) · 5.49 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
// sync.js — remote operations against Supabase (Storage + Postgres + Realtime).
import { getSupabase } from './supabase-client.js';
function ext(mime) {
if (/mp4|m4a|aac/i.test(mime)) return 'm4a';
if (/wav/i.test(mime)) return 'wav';
if (/ogg/i.test(mime)) return 'ogg';
return 'webm';
}
// Upload audio to Storage, then upsert the memo row. Idempotent (safe to retry) because the
// memo id is the primary key and the storage path is derived from it.
export async function pushMemo(memo, userId) {
const sb = await getSupabase();
if (!sb) throw new Error('no backend');
const path = `${memo.id}.${ext(memo.mimeType || '')}`;
const up = await sb.storage.from('memos').upload(path, memo.blob, {
contentType: memo.mimeType || 'application/octet-stream',
upsert: true,
});
if (up.error) throw up.error;
const row = {
id: memo.id,
sender_id: userId,
created_at: new Date(memo.createdAt).toISOString(),
duration_ms: Math.round(memo.durationMs || 0),
mime_type: memo.mimeType || 'audio/webm',
audio_path: path,
title: memo.title || null,
transcript: memo.transcript || null,
};
if (memo.replyToId) { row.reply_to_id = memo.replyToId; row.reply_to_ms = memo.replyToMs != null ? Math.round(memo.replyToMs) : null; }
// ignoreDuplicates → "ON CONFLICT DO NOTHING": only needs INSERT privilege (the migration-v2 column
// GRANT revokes UPDATE on most columns, which would otherwise break a merge-upsert). The memo id is a
// fresh UUID, so a duplicate just means a prior push already landed — an idempotent no-op is correct.
let { error } = await sb.from('memos').upsert(row, { onConflict: 'id', ignoreDuplicates: true });
if (error && memo.replyToId && /reply_to/.test(error.message || '')) {
// migration-v2 not run yet → retry without reply columns so the memo still syncs
delete row.reply_to_id; delete row.reply_to_ms;
({ error } = await sb.from('memos').upsert(row, { onConflict: 'id', ignoreDuplicates: true }));
}
if (error) throw error;
return path;
}
export async function pullMemos() {
const sb = await getSupabase();
const { data, error } = await sb.from('memos').select('*').order('created_at', { ascending: false });
if (error) throw error;
return data || [];
}
export async function pullListens(userId) {
const sb = await getSupabase();
const { data, error } = await sb.from('memo_listens').select('memo_id').eq('user_id', userId);
if (error) throw error;
return data || [];
}
export async function markListenedRemote(memoId, userId) {
const sb = await getSupabase();
const { error } = await sb.from('memo_listens').upsert(
{ memo_id: memoId, user_id: userId },
{ onConflict: 'memo_id,user_id', ignoreDuplicates: true }
);
if (error) throw error;
}
export async function unmarkListenedRemote(memoId, userId) {
const sb = await getSupabase();
const { error } = await sb.from('memo_listens').delete().eq('memo_id', memoId).eq('user_id', userId);
if (error) throw error;
}
export async function downloadAudio(path) {
const sb = await getSupabase();
const { data, error } = await sb.storage.from('memos').download(path);
if (error) {
// Surface the real reason (status/message) — this is the failure behind "Could not load this memo"
// for the other person's audio; usually a Storage RLS / members-allowlist gap.
console.warn('downloadAudio failed', { path, status: error?.status, message: error?.message || String(error) });
throw error;
}
return data; // Blob
}
// Update a memo's transcript on the server (requires the memos UPDATE policy from migration-v2.sql).
export async function updateMemoTranscript(memoId, transcript, chunks) {
const sb = await getSupabase();
const { error } = await sb.from('memos').update({ transcript: transcript || null, transcript_chunks: chunks || null }).eq('id', memoId);
if (error) throw error;
}
// Reactions (requires the memo_reactions table from migration-v2.sql). Best-effort — callers ignore errors.
export async function setReactionRemote(memoId, userId, reaction) {
const sb = await getSupabase();
if (reaction) {
const { error } = await sb.from('memo_reactions').upsert({ memo_id: memoId, user_id: userId, reaction }, { onConflict: 'memo_id,user_id' });
if (error) throw error;
} else {
const { error } = await sb.from('memo_reactions').delete().eq('memo_id', memoId).eq('user_id', userId);
if (error) throw error;
}
}
export async function fetchReactions() {
const sb = await getSupabase();
const { data, error } = await sb.from('memo_reactions').select('memo_id, user_id, reaction');
if (error) throw error;
return data || [];
}
export async function subscribeReactions(onChange) {
const sb = await getSupabase();
if (!sb) return null;
return sb.channel('reactions-realtime')
.on('postgres_changes', { event: '*', schema: 'public', table: 'memo_reactions' }, (p) => onChange(p))
.subscribe();
}
export async function fetchProfiles() {
const sb = await getSupabase();
const { data, error } = await sb.from('profiles').select('*');
if (error) throw error;
return data || [];
}
export async function subscribeMemoInserts(onInsert) {
const sb = await getSupabase();
if (!sb) return null;
const channel = sb
.channel('memos-realtime')
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'memos' }, (payload) => onInsert(payload.new))
.subscribe();
return channel;
}
export async function removeChannel(channel) {
const sb = await getSupabase();
if (sb && channel) sb.removeChannel(channel);
}