@blackcode_sa/metaestetics-api 1.7.4 → 1.7.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@blackcode_sa/metaestetics-api",
3
3
  "private": false,
4
- "version": "1.7.4",
4
+ "version": "1.7.5",
5
5
  "description": "Firebase authentication service with anonymous upgrade support",
6
6
  "main": "./dist/index.js",
7
7
  "module": "./dist/index.mjs",
@@ -0,0 +1,330 @@
1
+ import { Auth } from "firebase/auth";
2
+ import { Firestore, Timestamp } from "firebase/firestore";
3
+ import { FirebaseApp } from "firebase/app";
4
+ import {
5
+ ref,
6
+ uploadBytes,
7
+ getDownloadURL,
8
+ deleteObject,
9
+ listAll,
10
+ FirebaseStorage,
11
+ } from "firebase/storage";
12
+ import {
13
+ doc,
14
+ getDoc,
15
+ setDoc,
16
+ updateDoc,
17
+ arrayUnion,
18
+ arrayRemove,
19
+ collection,
20
+ query,
21
+ where,
22
+ limit,
23
+ getDocs,
24
+ deleteDoc,
25
+ orderBy,
26
+ } from "firebase/firestore";
27
+ import { BaseService } from "../base.service";
28
+
29
+ /**
30
+ * Enum for media access levels
31
+ */
32
+ export enum MediaAccessLevel {
33
+ PUBLIC = "public",
34
+ PRIVATE = "private",
35
+ CONFIDENTIAL = "confidential",
36
+ }
37
+
38
+ /**
39
+ * Media file metadata interface
40
+ */
41
+ export interface MediaMetadata {
42
+ id: string;
43
+ name: string;
44
+ url: string;
45
+ contentType: string;
46
+ size: number;
47
+ createdAt: Timestamp;
48
+ accessLevel: MediaAccessLevel;
49
+ ownerId: string;
50
+ collectionName: string;
51
+ path: string;
52
+ updatedAt?: Timestamp;
53
+ }
54
+
55
+ const MEDIA_METADATA_COLLECTION = "media_metadata";
56
+
57
+ export class MediaService extends BaseService {
58
+ constructor(db: Firestore, auth: Auth, app: FirebaseApp) {
59
+ super(db, auth, app);
60
+ }
61
+
62
+ /**
63
+ * Upload a media file, store its metadata, and return the metadata including the URL.
64
+ * @param file - The file to upload.
65
+ * @param ownerId - ID of the owner (user, patient, clinic, etc.).
66
+ * @param accessLevel - Access level (public, private, confidential).
67
+ * @param collectionName - The logical collection name this media belongs to (e.g., 'patient_profile_pictures', 'clinic_logos').
68
+ * @param originalFileName - Optional: the original name of the file, if not using file.name.
69
+ * @returns Promise with the media metadata.
70
+ */
71
+ async uploadMedia(
72
+ file: File,
73
+ ownerId: string,
74
+ accessLevel: MediaAccessLevel,
75
+ collectionName: string,
76
+ originalFileName?: string
77
+ ): Promise<MediaMetadata> {
78
+ const mediaId = this.generateId();
79
+ const fileNameToUse = originalFileName || file.name;
80
+ // Sanitize fileNameToUse if necessary, e.g., remove special characters or ensure a safe extension
81
+ const uniqueFileName = `${mediaId}-${fileNameToUse}`;
82
+ const filePath = `media/${accessLevel}/${ownerId}/${uniqueFileName}`;
83
+
84
+ console.log(`[MediaService] Uploading file to: ${filePath}`);
85
+
86
+ const storageRef = ref(this.storage, filePath);
87
+
88
+ try {
89
+ const uploadResult = await uploadBytes(storageRef, file, {
90
+ contentType: file.type,
91
+ });
92
+ console.log("[MediaService] File uploaded successfully", uploadResult);
93
+
94
+ const downloadURL = await getDownloadURL(uploadResult.ref);
95
+ console.log("[MediaService] Got download URL:", downloadURL);
96
+
97
+ const metadata: MediaMetadata = {
98
+ id: mediaId,
99
+ name: fileNameToUse,
100
+ url: downloadURL,
101
+ contentType: file.type,
102
+ size: file.size,
103
+ createdAt: Timestamp.now(),
104
+ accessLevel: accessLevel,
105
+ ownerId: ownerId,
106
+ collectionName: collectionName,
107
+ path: filePath, // Store the full storage path
108
+ };
109
+
110
+ const metadataDocRef = doc(this.db, MEDIA_METADATA_COLLECTION, mediaId);
111
+ await setDoc(metadataDocRef, metadata);
112
+ console.log("[MediaService] Metadata stored in Firestore:", mediaId);
113
+
114
+ return metadata;
115
+ } catch (error) {
116
+ console.error("[MediaService] Error during media upload:", error);
117
+ // Consider more specific error handling or re-throwing a custom error
118
+ throw error;
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Get media metadata from Firestore by its ID.
124
+ * @param mediaId - ID of the media.
125
+ * @returns Promise with the media metadata or null if not found.
126
+ */
127
+ async getMediaMetadata(mediaId: string): Promise<MediaMetadata | null> {
128
+ console.log(`[MediaService] Getting media metadata for ID: ${mediaId}`);
129
+ const docRef = doc(this.db, MEDIA_METADATA_COLLECTION, mediaId);
130
+ const docSnap = await getDoc(docRef);
131
+
132
+ if (docSnap.exists()) {
133
+ console.log("[MediaService] Metadata found:", docSnap.data());
134
+ return docSnap.data() as MediaMetadata;
135
+ }
136
+ console.log("[MediaService] No metadata found for ID:", mediaId);
137
+ return null;
138
+ }
139
+
140
+ /**
141
+ * Get media metadata from Firestore by its public URL.
142
+ * @param url - The public URL of the media file.
143
+ * @returns Promise with the media metadata or null if not found.
144
+ */
145
+ async getMediaMetadataByUrl(url: string): Promise<MediaMetadata | null> {
146
+ console.log(`[MediaService] Getting media metadata by URL: ${url}`);
147
+ const q = query(
148
+ collection(this.db, MEDIA_METADATA_COLLECTION),
149
+ where("url", "==", url),
150
+ limit(1)
151
+ );
152
+
153
+ try {
154
+ const querySnapshot = await getDocs(q);
155
+ if (!querySnapshot.empty) {
156
+ const metadata = querySnapshot.docs[0].data() as MediaMetadata;
157
+ console.log("[MediaService] Metadata found by URL:", metadata);
158
+ return metadata;
159
+ }
160
+ console.log("[MediaService] No metadata found for URL:", url);
161
+ return null;
162
+ } catch (error) {
163
+ console.error("[MediaService] Error fetching metadata by URL:", error);
164
+ throw error;
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Delete media from storage and remove metadata from Firestore.
170
+ * @param mediaId - ID of the media to delete.
171
+ */
172
+ async deleteMedia(mediaId: string): Promise<void> {
173
+ console.log(`[MediaService] Deleting media with ID: ${mediaId}`);
174
+ const metadata = await this.getMediaMetadata(mediaId);
175
+
176
+ if (!metadata) {
177
+ console.warn(
178
+ `[MediaService] Metadata not found for media ID ${mediaId}. Cannot delete.`
179
+ );
180
+ // Optionally throw an error or return a status
181
+ return;
182
+ }
183
+
184
+ const storageFileRef = ref(this.storage, metadata.path);
185
+
186
+ try {
187
+ // Delete the file from Firebase Storage
188
+ await deleteObject(storageFileRef);
189
+ console.log(`[MediaService] File deleted from Storage: ${metadata.path}`);
190
+
191
+ // Delete the metadata from Firestore
192
+ const metadataDocRef = doc(this.db, MEDIA_METADATA_COLLECTION, mediaId);
193
+ await deleteDoc(metadataDocRef);
194
+ console.log(
195
+ `[MediaService] Metadata deleted from Firestore for ID: ${mediaId}`
196
+ );
197
+ } catch (error) {
198
+ console.error(`[MediaService] Error deleting media ${mediaId}:`, error);
199
+ // Handle specific errors, e.g., file not found in storage, permissions issues
200
+ // If Firestore delete fails after storage delete, there might be an orphaned metadata entry.
201
+ // Consider how to handle such inconsistencies or if it's acceptable.
202
+ throw error;
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Update media access level. This is a complex operation if it involves moving the file.
208
+ * For now, this will only update the metadata. True access control might need storage path changes and Firestore rule updates.
209
+ * @param mediaId - ID of the media to update.
210
+ * @param newAccessLevel - New access level.
211
+ * @returns Promise with the updated media metadata.
212
+ */
213
+ async updateMediaAccessLevel(
214
+ mediaId: string,
215
+ newAccessLevel: MediaAccessLevel
216
+ ): Promise<MediaMetadata | null> {
217
+ console.log(
218
+ `[MediaService] Updating access level for media ID: ${mediaId} to ${newAccessLevel}`
219
+ );
220
+ const metadata = await this.getMediaMetadata(mediaId);
221
+ if (!metadata) {
222
+ console.warn(
223
+ `[MediaService] Metadata not found for media ID ${mediaId}. Cannot update access level.`
224
+ );
225
+ return null;
226
+ }
227
+
228
+ // IMPORTANT: This only updates the metadata field.
229
+ // If your Firebase Storage rules depend on the path, this won't change file accessibility directly.
230
+ // A full implementation might involve moving the file in storage to a new path (e.g., media/newAccessLevel/...),
231
+ // updating the URL, and then updating the metadata. This is significantly more complex.
232
+
233
+ const metadataDocRef = doc(this.db, MEDIA_METADATA_COLLECTION, mediaId);
234
+ const updateData = {
235
+ accessLevel: newAccessLevel,
236
+ updatedAt: Timestamp.now(),
237
+ };
238
+
239
+ try {
240
+ await updateDoc(metadataDocRef, updateData);
241
+ console.log(`[MediaService] Media access level updated for ${mediaId}.`);
242
+ // Return the updated metadata
243
+ return { ...metadata, ...updateData } as MediaMetadata;
244
+ } catch (error) {
245
+ console.error(
246
+ `[MediaService] Error updating media access level for ${mediaId}:`,
247
+ error
248
+ );
249
+ throw error;
250
+ }
251
+ }
252
+
253
+ /**
254
+ * List all media for an owner, optionally filtered by collection and access level.
255
+ * @param ownerId - ID of the owner.
256
+ * @param collectionName - Optional: Filter by collection name.
257
+ * @param accessLevel - Optional: Filter by access level.
258
+ * @param count - Optional: Number of items to fetch.
259
+ * @param startAfterId - Optional: ID of the document to start after (for pagination).
260
+ */
261
+ async listMedia(
262
+ ownerId: string,
263
+ collectionName?: string,
264
+ accessLevel?: MediaAccessLevel,
265
+ count?: number,
266
+ startAfterId?: string // Using ID for pagination simplicity with Firestore
267
+ ): Promise<MediaMetadata[]> {
268
+ console.log(`[MediaService] Listing media for owner: ${ownerId}`);
269
+ let qConstraints: any[] = [where("ownerId", "==", ownerId)];
270
+
271
+ if (collectionName) {
272
+ qConstraints.push(where("collectionName", "==", collectionName));
273
+ }
274
+ if (accessLevel) {
275
+ qConstraints.push(where("accessLevel", "==", accessLevel));
276
+ }
277
+
278
+ qConstraints.push(orderBy("createdAt", "desc")); // Example ordering
279
+
280
+ if (count) {
281
+ qConstraints.push(limit(count));
282
+ }
283
+
284
+ if (startAfterId) {
285
+ const startAfterDoc = await this.getMediaMetadata(startAfterId);
286
+ if (startAfterDoc) {
287
+ // Firestore's startAfter needs a DocumentSnapshot or field values from it.
288
+ // For simplicity, if using cursor-based pagination with getDocs, you'd pass the snapshot.
289
+ // If building query manually, it's more complex with orderBy fields.
290
+ // This part needs refinement based on actual pagination strategy.
291
+ // For now, let's assume we'd fetch the doc snapshot if needed or simplify.
292
+ // console.log("[MediaService] Pagination: starting after document corresponding to ID", startAfterId);
293
+ // This simple approach of passing ID won't directly work with Firestore's startAfter without fetching the document snapshot first.
294
+ // A more robust solution would involve passing the actual DocumentSnapshot or specific field values of the last retrieved document.
295
+ }
296
+ }
297
+
298
+ const finalQuery = query(
299
+ collection(this.db, MEDIA_METADATA_COLLECTION),
300
+ ...qConstraints
301
+ );
302
+
303
+ try {
304
+ const querySnapshot = await getDocs(finalQuery);
305
+ const mediaList = querySnapshot.docs.map(
306
+ (doc) => doc.data() as MediaMetadata
307
+ );
308
+ console.log(`[MediaService] Found ${mediaList.length} media items.`);
309
+ return mediaList;
310
+ } catch (error) {
311
+ console.error("[MediaService] Error listing media:", error);
312
+ throw error;
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Get download URL for media. (Convenience, as URL is in metadata)
318
+ * @param mediaId - ID of the media.
319
+ */
320
+ async getMediaDownloadUrl(mediaId: string): Promise<string | null> {
321
+ console.log(`[MediaService] Getting download URL for media ID: ${mediaId}`);
322
+ const metadata = await this.getMediaMetadata(mediaId);
323
+ if (metadata && metadata.url) {
324
+ console.log(`[MediaService] URL found: ${metadata.url}`);
325
+ return metadata.url;
326
+ }
327
+ console.log(`[MediaService] URL not found for media ID: ${mediaId}`);
328
+ return null;
329
+ }
330
+ }
@@ -1,5 +1,5 @@
1
1
  import { User as FirebaseAuthUser } from "firebase/auth";
2
- import { Timestamp, FieldValue } from "firebase/firestore";
2
+ import { Timestamp, FieldValue, serverTimestamp } from "firebase/firestore";
3
3
 
4
4
  // User tipovi
5
5
  export enum UserRole {
@@ -14,9 +14,9 @@ export interface User {
14
14
  email: string | null;
15
15
  roles: UserRole[];
16
16
  isAnonymous: boolean;
17
- createdAt: Timestamp | FieldValue;
18
- updatedAt: Timestamp | FieldValue;
19
- lastLoginAt: Timestamp | FieldValue;
17
+ createdAt: Timestamp | FieldValue | (() => FieldValue);
18
+ updatedAt: Timestamp | FieldValue | (() => FieldValue);
19
+ lastLoginAt: Timestamp | FieldValue | (() => FieldValue);
20
20
  patientProfile?: string;
21
21
  practitionerProfile?: string;
22
22
  adminProfile?: string;
@@ -27,9 +27,9 @@ export interface CreateUserData {
27
27
  email: string | null;
28
28
  roles: UserRole[];
29
29
  isAnonymous: boolean;
30
- createdAt: FieldValue;
31
- updatedAt: FieldValue;
32
- lastLoginAt: FieldValue;
30
+ createdAt: FieldValue | Timestamp | (() => FieldValue);
31
+ updatedAt: FieldValue | Timestamp | (() => FieldValue);
32
+ lastLoginAt: FieldValue | Timestamp | (() => FieldValue);
33
33
  }
34
34
 
35
35
  export const USERS_COLLECTION = "users";
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { UserRole } from "../types";
3
- import { Timestamp, FieldValue } from "firebase/firestore";
3
+ import { Timestamp, FieldValue, serverTimestamp } from "firebase/firestore";
4
4
 
5
5
  export const emailSchema = z
6
6
  .string()
@@ -24,21 +24,32 @@ export const userRolesSchema = z
24
24
  .min(1, "User must have at least one role")
25
25
  .max(3, "User cannot have more than 3 roles");
26
26
 
27
- export const timestampSchema = z.custom<Timestamp | FieldValue>((data) => {
28
- // If it's a serverTimestamp (FieldValue), it's valid
27
+ export const timestampSchema = z.custom<
28
+ Timestamp | FieldValue | (() => FieldValue)
29
+ >((data) => {
30
+ // If it's serverTimestamp (FieldValue), it's valid
29
31
  if (data && typeof data === "object" && "isEqual" in data) {
30
32
  return true;
31
33
  }
32
34
 
33
35
  // If it's a Timestamp object, validate its structure
34
- return (
36
+ if (
35
37
  data &&
36
38
  typeof data === "object" &&
37
39
  "toDate" in data &&
38
40
  "seconds" in data &&
39
41
  "nanoseconds" in data
40
- );
41
- }, "Must be a Timestamp object or serverTimestamp");
42
+ ) {
43
+ return true;
44
+ }
45
+
46
+ // If it's the serverTimestamp function reference
47
+ if (data === serverTimestamp) {
48
+ return true;
49
+ }
50
+
51
+ return false;
52
+ }, "Must be a Timestamp object, FieldValue or serverTimestamp function");
42
53
 
43
54
  /**
44
55
  * Validaciona šema za clinic admin opcije pri kreiranju