Skip to content
Draft
Show file tree
Hide file tree
Changes from 10 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
24 changes: 24 additions & 0 deletions .vscode/tasks.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,30 @@
"label": "start:local",
"command": "npm",
"args": ["run", "start:local"]
},
{
"label": "test:functions",
"command": "npm",
"options": {
"cwd": "${workspaceFolder}/functions"
},
"args": ["run", "test"]
},
{
"label": "serve:functions",
"command": "npm",
"options": {
"cwd": "${workspaceFolder}/functions"
},
"args": ["run", "serve"]
},
{
"label": "build:functions",
"command": "npm",
"options": {
"cwd": "${workspaceFolder}/functions"
},
"args": ["run", "build"]
}
]
}
15 changes: 0 additions & 15 deletions functions/jest.config.js

This file was deleted.

12,779 changes: 8,305 additions & 4,474 deletions functions/package-lock.json

Large diffs are not rendered by default.

34 changes: 21 additions & 13 deletions functions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,28 +16,36 @@
},
"main": "lib/index.js",
"dependencies": {
"@fastify/auth": "^5.0.3",
"@fastify/cors": "^11.1.0",
"@fastify/swagger": "^9.5.2",
"@fastify/swagger-ui": "^5.2.3",
"@fastify/type-provider-typebox": "^6.0.0",
"@google-cloud/storage": "^5.11.1",
"@scalar/fastify-api-reference": "^1.38.1",
"@sinclair/typebox": "^0.34.41",
"@types/jest": "^27.4.0",
"@types/jest-expect-message": "^1.0.3",
"fastify": "^5.6.1",
"fastify-plugin": "^5.1.0",
"firebase": "^8.8.1",
"firebase-admin": "^12.1.0",
"firebase-functions": "^5.0.1",
"firebase-functions-test": "^3.2.0",
"firebase-tools": "13.6.0",
"form-data": "^3.0.1",
"firebase-admin": "^13.5.0",
"firebase-functions": "^6.6.0",
"firebase-functions-test": "^3.2.1",
"firebase-tools": "14.23.0",
"lodash": "^4.17.21",
"node-fetch": "^2.6.1"
"ts-custom-error": "^3.3.1"
},
"devDependencies": {
"@types/node-fetch": "^2.5.12",
"@typescript-eslint/eslint-plugin": "^5.12.0",
"@typescript-eslint/parser": "^5.12.0",
"eslint": "^8.9.0",
"@eslint/js": "^9.39.0",
"eslint": "^9.39.0",
"eslint-config-google": "^0.14.0",
"eslint-plugin-import": "^2.25.4",
"eslint-plugin-import": "^2.32.0",
"firestore-vitest": "^0.20.2",
"jest-expect-message": "^1.0.2",
"typescript": "^4.9.5",
"vitest": "^2.0.5"
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.2",
"vitest": "^4.0.4"
},
"private": true
}
15 changes: 15 additions & 0 deletions functions/src/api/addContentTypeParserForServerless.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { FastifyInstance } from 'fastify'

export const addContentTypeParserForServerless = (fastify: FastifyInstance) => {
// For serverless compatibility
fastify.addContentTypeParser('application/json', {}, (req, body, done) => {
done(null, (body as any).body)
})
fastify.addContentTypeParser(
'multipart/form-data',
{},
(req, body, done) => {
done(null, req)
}
)
}
43 changes: 43 additions & 0 deletions functions/src/api/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import Fastify from 'fastify'
import { addContentTypeParserForServerless } from './addContentTypeParserForServerless'
import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'
import { App as FirebaseApp } from 'firebase-admin/app'
import { apiKeyPlugin } from './plugins/apiKeyPlugin'
import { firebasePlugin } from './plugins/firebasePlugin'
import cors from '@fastify/cors'
import { openAPIPlugin } from './plugins/openAPIPlugin'
import { fastifyErrorHandler } from './others/fastifyErrorHandler'
import { fastifyNotFoundHandler } from './others/fastifyNotFoundHandler'
import { organizationsRoutes } from './routes/organizations'

type Firebase = FirebaseApp
declare module 'fastify' {
interface FastifyInstance {
firebase: Firebase
}
}

export async function createFastifyAPI() {
const fastify = Fastify({
logger: true, // always enable full log
}).withTypeProvider<TypeBoxTypeProvider>()
addContentTypeParserForServerless(fastify)

fastify.register(firebasePlugin)
fastify.register(apiKeyPlugin)
fastify.register(cors, {
origin: '*',
})
fastify.register(openAPIPlugin)
fastify.setErrorHandler(fastifyErrorHandler)
fastify.setNotFoundHandler(fastifyNotFoundHandler)

fastify.addHook('onSend', (_, reply, _2, done: () => void) => {
reply.header('Cache-Control', 'must-revalidate,no-cache,no-store')
done()
})

fastify.register(organizationsRoutes, { prefix: '/organizations' })

return fastify
}
95 changes: 95 additions & 0 deletions functions/src/api/dao/OrganizationDao.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { App as FirebaseApp } from 'firebase-admin/app'
import { getFirestore } from 'firebase-admin/firestore'
import { Organization, HydratedOrganization } from '../../types/Organization'
import { NotFoundError } from '../others/Errors'
import { APIKey } from '../plugins/APIKey'
import { UserDao } from './UserDao'
import { User } from '../../types/User'

const ORGANIZATION_COLLECTION = 'organizations'

export class OrganizationDao {
public static async getOrganizationFromId(
firebaseApp: FirebaseApp,
organizationId: string
): Promise<Organization> {
const db = getFirestore(firebaseApp)
const organizationDoc = await db
.collection(ORGANIZATION_COLLECTION)
.doc(organizationId)
.get()

if (!organizationDoc.exists) {
throw new NotFoundError('Organization not found')
}

return {
id: organizationDoc.id,
...organizationDoc.data(),
} as Organization
}

public static async getOrganizationFromApiKey(
firebaseApp: FirebaseApp,
apiKey: APIKey
): Promise<Organization | null> {
const db = getFirestore(firebaseApp)

const doc = await db
.collection(ORGANIZATION_COLLECTION)
.where('apiKey', '==', apiKey.apiKey)
.get()

if (!doc || doc.empty || doc.docs.length === 0) {
throw new NotFoundError('Organization not found')
}

return {
id: doc.docs[0].id,
...doc.docs[0].data(),
} as Organization
}

public static async hydrateOrganization(
firebaseApp: FirebaseApp,
organization: Organization
): Promise<HydratedOrganization> {
const allUserIds = [
organization.ownerUserId,
...(organization.adminUserIds || []),
...(organization.editorUserIds || []),
...(organization.viewerUserIds || []),
]

const usersMap = await UserDao.getUsersByIds(firebaseApp, allUserIds)

const getUser = (userId: string): User => {
return (
usersMap.get(userId) || {
id: userId,
displayName: undefined,
email: undefined,
photoUrl: undefined,
createdAt: undefined,
updatedAt: undefined,
}
)
}

const {
ownerUserId,
adminUserIds,
editorUserIds,
viewerUserIds,
...rest
} = organization

return {
...rest,
ownerUser: getUser(ownerUserId),
adminUsers: (adminUserIds || []).map(getUser),
editorUsers: (editorUserIds || []).map(getUser),
viewerUsers: (viewerUserIds || []).map(getUser),
}
}
}
46 changes: 46 additions & 0 deletions functions/src/api/dao/ProjectDao.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { App as FirebaseApp } from 'firebase-admin/app'
import { getFirestore } from 'firebase-admin/firestore'
import { Project } from '../../types/Project'
import { NotFoundError } from '../others/Errors'
import { APIKey } from '../plugins/APIKey'

const PROJECT_COLLECTION = 'projects'

export class ProjectDao {
public static async getProjectFromId(
firebaseApp: FirebaseApp,
projectId: string
): Promise<Project> {
const db = getFirestore(firebaseApp)
const doc = await db.collection(PROJECT_COLLECTION).doc(projectId).get()

if (!doc.exists) {
throw new NotFoundError('Project not found')
}

return {
id: doc.id,
...doc.data(),
} as Project
}

public static async getProjectFromApiKey(
firebaseApp: FirebaseApp,
apiKey: APIKey
): Promise<Project | null> {
const db = getFirestore(firebaseApp)
const doc = await db
.collection(PROJECT_COLLECTION)
.where('apiKey', '==', apiKey.apiKey)
.get()

if (!doc || doc.empty || doc.docs.length === 0) {
throw new NotFoundError('Project not found')
}

return {
id: doc.docs[0].id,
...doc.docs[0].data(),
} as Project
}
}
59 changes: 59 additions & 0 deletions functions/src/api/dao/UserDao.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { App as FirebaseApp } from 'firebase-admin/app'
import { getFirestore } from 'firebase-admin/firestore'
import { User } from '../../types/User'

const USER_COLLECTION = 'users'

export class UserDao {
public static async getUsersByIds(
firebaseApp: FirebaseApp,
userIds: string[]
): Promise<Map<string, User>> {
if (userIds.length === 0) {
return new Map()
}

const db = getFirestore(firebaseApp)
const uniqueUserIds = [...new Set(userIds)]

const userPromises = uniqueUserIds.map(async (userId) => {
try {
const userDoc = await db
.collection(USER_COLLECTION)
.doc(userId)
.get()

if (!userDoc.exists) {
return null
}

const data = userDoc.data()
return {
id: userDoc.id,
displayName: data?.displayName,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parsing error: Expression expected

email: data?.email,
photoUrl: data?.photoURL || data?.photoUrl,
createdAt:
data?.createdAt?.toDate?.()?.toISOString() ||
data?.createdAt,
updatedAt:
data?.updatedAt?.toDate?.()?.toISOString() ||
data?.updatedAt,
} as User
} catch (error) {
return null
}
})

const users = await Promise.all(userPromises)
const userMap = new Map<string, User>()

users.forEach((user) => {
if (user) {
userMap.set(user.id, user)
}
})

return userMap
}
}
2 changes: 2 additions & 0 deletions functions/src/api/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const isNodeEnvDev = process.env.NODE_ENV === 'development'
export const isNodeEnvTest = process.env.NODE_ENV === 'test'
Loading
Loading