Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions README_FIREBASE_SERVICE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# Firebase Service & Repositories

This folder contains the client-side Firebase integration for the Levante Dashboard: a singleton **FirebaseService** and **repositories** that call Firebase Cloud Functions. Repositories are the preferred way to query or mutate data via the backend instead of calling Firestore directly.

## FirebaseService

**Location:** `src/firebase/Service.ts`

`FirebaseService` is a static singleton that holds the initialized Firebase app and its core services (Auth, Firestore, Functions). It is the single entry point for Firebase in the app. The Auth service is just for the firebase emulator. For the app, we use the firekit authentication.

### What it does

- **Initialization:** `FirebaseService.initialize(config?, emulatorConfig?)` creates (or returns) the Firebase app and sets:
- `FirebaseService.app` – Firebase App instance
- `FirebaseService.auth` – Auth instance
- `FirebaseService.db` – Firestore instance
- `FirebaseService.functions` – Cloud Functions instance
- **Emulator support:** When `VITE_EMULATOR` is set and `emulatorConfig` is provided, it connects Auth, Firestore, and Functions to the configured emulator hosts/ports.
- **Single app name:** Uses the app name `'admin'` so the same app is reused across the codebase.

### Usage

You typically do **not** call `FirebaseService` directly in app code. Repositories extend `Repository`, which calls `FirebaseService.initialize()` in their constructor and use `FirebaseService.functions` to invoke callable functions. Use `FirebaseService` only when you need direct access to `auth`, `db`, or `functions` outside of a repository.

---

## Repositories

Repositories live in `src/firebase/repositories/` and wrap **Firebase Callable Functions**. Each repository is usually named after a Firestore collection or domain (e.g. `AdministrationsRepository` for administrations). They expose methods that call a Cloud Function and return the function’s data.

### Base class: Repository

**Location:** `src/firebase/Repository.ts`

- It is intended to be used as a base class for concrete repositories.
- In the constructor, it ensures Firebase is initialized via `FirebaseService.initialize()`.
- It provides a protected `call<TData, TResponse>(functionName, data?)` method that uses `httpsCallable` with `FirebaseService.functions`, sends `data`, and returns the callable result’s payload. Errors are logged and rethrown.

Your repository methods should call `this.call(...)` with the correct Cloud Function name and typed params/response.

---

## Creating a new repository

Use the generator script so that a new repository is created with the right name, place, and a minimal template.

### 1. Run the script via npm

From the **levante-dashboard** root:

```bash
npm run repository:new "CollectionName"
```

- **CollectionName** should be the singular or logical name (e.g. the Firestore collection name or domain). Examples: `"Users"`, `"Administrations"`, `"Groups"`.
- The script creates a single file: `src/firebase/repositories/<CollectionName>Repository.ts`.
- If that file already exists, the script exits with an error.

**Example:**

```bash
npm run repository:new "Users"
```

This creates `src/firebase/repositories/UsersRepository.ts` with a class `UsersRepository`, an exported singleton (e.g. `usersRepository`), and placeholder types and one example method (`exampleFn`).

### 2. Implement methods that call Cloud Functions

After generation:

1. **Rename or remove the example method** and add methods that match your Cloud Functions (e.g. `getUsers`, `createUser`).
2. **Define TypeScript interfaces** (or use Zod schemas) for:
- Parameters passed to the callable (e.g. `GetUsersParams`)
- The shape of the data returned by the function (e.g. `GetUsersResponse` with a `data` field if that’s what the function returns).
3. **Call the Cloud Function** via `this.call<ParamsType, ResponseType>(functionName, params)` and return the part of the response your callers need (e.g. `response.data`).

**Example:** After running `npm run repository:new "Users"`, you might replace the template with something like:

```ts
import { Repository } from '@/firebase/Repository';

interface GetUsersParams {
siteId: string;
idsOnly?: boolean;
}

interface User {
id: string;
email: string;
}

interface GetUsersResponse {
data: User[];
}

class UsersRepository extends Repository {
constructor() {
super();
}

async getUsers(params: GetUsersParams): Promise<User[]> {
const response = await this.call<GetUsersParams, GetUsersResponse>('getUsers', params);
return response.data;
}
}

export const usersRepository = new UsersRepository();
```

- The first type parameter of `call` is the payload you send; the second is the shape of the callable’s return value (what `response.data` has).
- The Cloud Function name (e.g. `'getUsers'`) must match the name of the callable function deployed in your Firebase project.

### 3. Use the repository in the app

Import the exported singleton and call its methods where you need to talk to the backend (e.g. in a composable, store, or component):

```ts
import { usersRepository } from '@/firebase/repositories/UsersRepository';

const users = await usersRepository.getUsers({ siteId: '...' });
```

---

## Summary

| Item | Purpose |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **FirebaseService** | Singleton that initializes and exposes Firebase `app`, `auth`, `db`, and `functions`. Used by `Repository` and when you need direct Firebase access. |
| **Repository** | Base class that initializes Firebase and provides `call()` to invoke callable Cloud Functions with typed params and response. |
| **generate-repository.sh** | Run via `npm run repository:new "Name"` to create `repositories/<Name>Repository.ts`; then add methods that call your Cloud Functions and return their data. |

Repositories are the standard place to add new functions that call Firebase Cloud Functions; keep Firestore collection naming in mind when choosing the name for the script.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@
"i18n:validate": "node src/translations/tools/validate-csvs.js",
"i18n:safety-check": "node src/translations/tools/translation-safety-check.js",
"i18n:add-locale": "node src/translations/tools/add-locale-column.js",
"i18n:sync": "npm run i18n:consolidate && npm run i18n:crowdin:upload && npm run i18n:crowdin:download && npm run i18n:csv-to-json && npm run i18n:validate"
"i18n:sync": "npm run i18n:consolidate && npm run i18n:crowdin:upload && npm run i18n:crowdin:download && npm run i18n:csv-to-json && npm run i18n:validate",
"repository:new": "bash scripts/generate-repository.sh"
},
"dependencies": {
"@bdelab/roar-pa": "2.2.4",
Expand Down
55 changes: 55 additions & 0 deletions scripts/generate-repository.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
#!/bin/bash

NAME=$1

if [ -z "$NAME" ]; then
echo "❌ Missing name argument."
echo "Example: npm run repository:new \"Users\""
exit 1
fi

CLASS_NAME="${NAME}Repository"
INSTANCE_NAME="$(echo "${NAME:0:1}" | tr '[:upper:]' '[:lower:]')${NAME:1}Repository"
OUTPUT_DIR="src/firebase/repositories"
FILENAME="${OUTPUT_DIR}/${CLASS_NAME}.ts"

mkdir -p "$OUTPUT_DIR"

if [ -f "$FILENAME" ]; then
echo "❌ File already exists: $FILENAME"
exit 1
fi

cat > "$FILENAME" <<TEMPLATE
import { Repository } from '@/firebase/Repository';
interface ${NAME}Params {
[key: string]: unknown;
}
interface ${NAME}Return {
[key: string]: unknown;
}
interface ${NAME}ReturnResponse {
data: ${NAME}Return;
}
class ${CLASS_NAME} extends Repository {
constructor() {
super();
}
async exampleFn(params?: ${NAME}Params): Promise<${NAME}Return> {
const response = await this.call<${NAME}Params, ${NAME}ReturnResponse>(
'exampleFn',
params,
);
return response.data;
}
}
export const ${INSTANCE_NAME} = new ${CLASS_NAME}();
TEMPLATE

echo "✅ Repository created: $FILENAME"
19 changes: 19 additions & 0 deletions src/firebase/Repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { FirebaseService } from '@/firebase/Service';
import { httpsCallable, HttpsCallableResult } from 'firebase/functions';

export class Repository {
protected constructor() {
FirebaseService.initialize();
}

protected async call<TData = unknown, TResponse = unknown>(functionName: string, data?: TData): Promise<TResponse> {
try {
const callable = httpsCallable<TData, TResponse>(FirebaseService.functions, functionName);
const response: HttpsCallableResult<TResponse> = await callable(data);
return response?.data;
} catch (error) {
console.error(`[${functionName}]`, error);
throw error;
}
}
}
61 changes: 61 additions & 0 deletions src/firebase/Service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import levanteFirebaseConfig from '@/config/firebaseLevante';
import { FirebaseApp, getApp, getApps, initializeApp } from 'firebase/app';
import { Auth, connectAuthEmulator, getAuth } from 'firebase/auth';
import { connectFirestoreEmulator, Firestore, getFirestore } from 'firebase/firestore';
import { connectFunctionsEmulator, Functions, getFunctions } from 'firebase/functions';

export interface FirebaseServiceConfig {
apiKey: string;
appId: string;
authDomain: string;
messagingSenderId: string;
projectId: string;
storageBucket: string;
}

interface EmulatorConnection {
host: string;
port: number;
}

export interface EmulatorConfig {
auth: EmulatorConnection;
firestore: EmulatorConnection;
functions: EmulatorConnection;
hub?: EmulatorConnection;
logging?: EmulatorConnection;
tasks?: EmulatorConnection;
ui?: EmulatorConnection;
}

const APP_NAME = 'admin';

export class FirebaseService {
static app: FirebaseApp | null = null;
static auth: Auth;
static db: Firestore;
static functions: Functions;

private constructor() {}

static initialize(
config: FirebaseServiceConfig = levanteFirebaseConfig.admin,
emulatorConfig?: EmulatorConfig,
): FirebaseApp {
if (FirebaseService.app) return FirebaseService.app;

const existing = getApps().find((app) => app.name === APP_NAME);
FirebaseService.app = existing ? getApp(APP_NAME) : initializeApp(config, APP_NAME);
FirebaseService.auth = getAuth(FirebaseService.app);
FirebaseService.db = getFirestore(FirebaseService.app);
FirebaseService.functions = getFunctions(FirebaseService.app);

if (import.meta.env.VITE_EMULATOR && emulatorConfig) {
connectAuthEmulator(FirebaseService.auth, `http://${emulatorConfig.auth.host}:${emulatorConfig.auth.port}`);
connectFirestoreEmulator(FirebaseService.db, emulatorConfig.firestore.host, emulatorConfig.firestore.port);
connectFunctionsEmulator(FirebaseService.functions, emulatorConfig.functions.host, emulatorConfig.functions.port);
}

return FirebaseService.app;
}
}
32 changes: 32 additions & 0 deletions src/firebase/repositories/AdministrationsRepository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Repository } from '@/firebase/Repository';

interface GetAdministrationsParams {
idsOnly: boolean;
testData: boolean;
}

// Replace the following interface with zod schema
interface Administration {
id: string;
}

interface GetAdministrationsResponse {
data: Administration[];
}

class AdministrationsRepository extends Repository {
constructor() {
super();
}

async getAdministrations(params?: GetAdministrationsParams): Promise<Administration[]> {
const response = await this.call<GetAdministrationsParams, GetAdministrationsResponse>(
'getAdministrations',
params,
);

return response.data;
}
}

export const administrationsRepository = new AdministrationsRepository();
Loading
Loading