Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add tRPC application server and Firestore database #2063

Merged
merged 1 commit into from
Jan 8, 2024
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
7 changes: 7 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ APP_NAME=Acme Co.
APP_HOSTNAME=localhost
APP_ORIGIN=http://localhost:5173
API_ORIGIN=https://api-mcfytwakla-uc.a.run.app
APP_STORAGE_BUCKET=example.com

# Google Cloud
# https://console.cloud.google.com/
GOOGLE_CLOUD_PROJECT=kriasoft
GOOGLE_CLOUD_REGION=us-central1
GOOGLE_CLOUD_DATABASE="(default)"
GOOGLE_CLOUD_CREDENTIALS={"type":"service_account","project_id":"example","private_key_id":"xxx","private_key":"-----BEGIN PRIVATE KEY-----\nxxxxx\n-----END PRIVATE KEY-----\n","client_email":"[email protected]","client_id":"xxxxx","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_x509_cert_url":"https://www.googleapis.com/robot/v1/metadata/x509/application%40example.iam.gserviceaccount.com"}

# Firebase
Expand All @@ -24,6 +26,11 @@ FIREBASE_APP_ID=1:736557952746:web:b5ee23841e24c0b883b193
FIREBASE_API_KEY=AIzaSyAZDmdeRWvlYgZpwm6LBCkYJM6ySIMF2Hw
FIREBASE_AUTH_DOMAIN=kriasoft.web.app

# OpenAI
# https://platform.openai.com/
OPENAI_ORGANIZATION=xxxxx
OPENAI_API_KEY=xxxxx

# Cloudflare
# https://dash.cloudflare.com/
# https://developers.cloudflare.com/api/tokens/create
Expand Down
5 changes: 5 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,23 +61,28 @@
"firestore",
"globby",
"hono",
"identitytoolkit",
"jamstack",
"kriasoft",
"localforage",
"miniflare",
"nodenext",
"notistack",
"oidc",
"openai",
"pathinfo",
"pino",
"pnpify",
"reactstarter",
"refetch",
"refetchable",
"relyingparty",
"sendgrid",
"signup",
"sourcemap",
"spdx",
"swapi",
"trpc",
"tslib",
"typechecking",
"vite",
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,11 @@ Be sure to join our [Discord channel](https://discord.com/invite/2nKEnKq) for as
`├──`[`.github`](.github) — GitHub configuration including CI/CD workflows<br>
`├──`[`.vscode`](.vscode) — VSCode settings including code snippets, recommended extensions etc.<br>
`├──`[`app`](./app) — Web application front-end built with [React](https://react.dev/) and [Joy UI](https://mui.com/joy-ui/getting-started/)<br>
`├──`[`db`](./db) — Firestore database schema, seed data, and admin tools<br>
`├──`[`edge`](./edge) — Cloudflare Workers (CDN) edge endpoint<br>
`├──`[`env`](./env) — Application settings, API keys, etc.<br>
`├──`[`scripts`](./scripts) — Automation scripts such as `yarn deploy`<br>
`├──`[`server`](./server) — Node.js application server built with [tRPC](https://trpc.io/)<br>
`├──`[`tsconfig.base.json`](./tsconfig.base.json) — The common/shared TypeScript configuration<br>
`└──`[`tsconfig.json`](./tsconfig.json) — The root TypeScript configuration<br>

Expand Down
2 changes: 1 addition & 1 deletion app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
"devDependencies": {
"@babel/core": "^7.23.7",
"@emotion/babel-plugin": "^11.11.0",
"@types/node": "^20.10.6",
"@types/node": "^20.10.7",
"@types/react": "^18.2.47",
"@types/react-dom": "^18.2.18",
"@vitejs/plugin-react": "^4.2.1",
Expand Down
20 changes: 20 additions & 0 deletions db/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Firestore Database

Database schema, security rules, indexes, and seed data for the [Firestore](https://cloud.google.com/firestore) database.

## Directory Structure

- [`/models`](./models/) — Database schema definitions using [Zod](https://zod.dev/).
- [`/seeds`](./seeds/) — Sample / reference data for the database.
- [`/scripts`](./scripts/) — Scripts for managing the database.
- [`/firestore.indexes.json`](./firestore.indexes.json) — Firestore indexes.
- [`/firestore.rules`](./firestore.rules) — Firestore security rules.

## Scripts

- `yarn workspace db seed` - Seed the database with data from [`/seeds`](./seeds/).

## References

- https://zod.dev/
- https://cloud.google.com/firestore
13 changes: 13 additions & 0 deletions db/firestore.indexes.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"indexes": [
{
"collectionGroup": "workspace",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "ownerId", "order": "ASCENDING" },
{ "fieldPath": "archived", "order": "DESCENDING" }
]
}
],
"fieldOverrides": []
}
19 changes: 19 additions & 0 deletions db/firestore.rules
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Firestore security rules.
// https://cloud.google.com/firestore/docs/security/get-started

rules_version = '2';

service cloud.firestore {
match /databases/{database}/documents {
match /workspace/{id} {
allow read: if request.auth != null && (
resource.data.ownerId = request.auth.uid ||
request.auth.token.admin == true
);
}

match /{document=**} {
allow read, write: if false;
}
}
}
6 changes: 6 additions & 0 deletions db/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/* SPDX-FileCopyrightText: 2014-present Kriasoft */
/* SPDX-License-Identifier: MIT */

export * from "./models";
export { testUsers } from "./seeds/01-users";
export { testWorkspaces } from "./seeds/02-workspaces";
4 changes: 4 additions & 0 deletions db/models/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/* SPDX-FileCopyrightText: 2014-present Kriasoft */
/* SPDX-License-Identifier: MIT */

export * from "./workspace";
16 changes: 16 additions & 0 deletions db/models/workspace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/* SPDX-FileCopyrightText: 2014-present Kriasoft */
/* SPDX-License-Identifier: MIT */

import { Timestamp } from "@google-cloud/firestore";
import { z } from "zod";

export const WorkspaceSchema = z.object({
name: z.string().max(100),
ownerId: z.string().max(50),
created: z.instanceof(Timestamp),
updated: z.instanceof(Timestamp),
archived: z.instanceof(Timestamp).nullable(),
});

export type Workspace = z.output<typeof WorkspaceSchema>;
export type WorkspaceInput = z.input<typeof WorkspaceSchema>;
30 changes: 30 additions & 0 deletions db/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"name": "db",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": {
"default": "./index.ts"
},
"./package.json": "./package.json"
},
"scripts": {
"seed": "vite-node ./scripts/seed.ts",
"test": "vitest"
},
"dependencies": {
"@google-cloud/firestore": "^7.1.0",
"@googleapis/identitytoolkit": "^8.0.0",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/node": "^20.10.7",
"dotenv": "^16.3.1",
"ora": "^8.0.1",
"typescript": "~5.3.3",
"vite": "~5.0.11",
"vite-node": "~1.1.3",
"vitest": "~1.1.3"
}
}
43 changes: 43 additions & 0 deletions db/scripts/seed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/* SPDX-FileCopyrightText: 2014-present Kriasoft */
/* SPDX-License-Identifier: MIT */

import { Firestore } from "@google-cloud/firestore";
import { configDotenv } from "dotenv";
import { relative, resolve } from "node:path";
import { oraPromise } from "ora";

const rootDir = resolve(__dirname, "../..");

// Load environment variables from .env files.
configDotenv({ path: resolve(rootDir, ".env.local") });
configDotenv({ path: resolve(rootDir, ".env") });

let db: Firestore | null = null;

// Seed the database with test / sample data.
try {
db = new Firestore({
projectId: process.env.GOOGLE_CLOUD_PROJECT,
databaseId: process.env.GOOGLE_CLOUD_DATABASE,
});

// Import all seed modules from the `/seeds` folder.
const files = import.meta.glob<boolean, string, SeedModule>("../seeds/*.ts");

// Sequentially seed the database with data from each module.
for (const [path, load] of Object.entries(files)) {
const message = `Seeding ${relative("../seeds", path)}`;
const action = (async () => {
const { seed } = await load();
await seed(db);
})();

await oraPromise(action, message);
}
} finally {
await db?.terminate();
}

type SeedModule = {
seed: (db: Firestore) => Promise<void>;
};
87 changes: 87 additions & 0 deletions db/seeds/01-users.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/* SPDX-FileCopyrightText: 2014-present Kriasoft */
/* SPDX-License-Identifier: MIT */

import {
AuthPlus,
identitytoolkit,
identitytoolkit_v3,
} from "@googleapis/identitytoolkit";

/**
* Test user accounts generated by https://randomuser.me/.
*/
export const testUsers: identitytoolkit_v3.Schema$UserInfo[] = [
{
localId: "test-erika",
screenName: "erika",
email: "[email protected]",
emailVerified: true,
phoneNumber: "+14788078434",
displayName: "Erika Pearson",
photoUrl: "https://randomuser.me/api/portraits/women/29.jpg",
rawPassword: "paloma",
createdAt: new Date("2024-01-01T12:00:00Z").getTime().toString(),
lastLoginAt: new Date("2024-01-01T12:00:00Z").getTime().toString(),
},
{
localId: "test-ryan",
screenName: "ryan",
email: "[email protected]",
emailVerified: true,
phoneNumber: "+16814758216",
displayName: "Ryan Hunt",
photoUrl: "https://randomuser.me/api/portraits/men/20.jpg",
rawPassword: "baggins",
createdAt: new Date("2024-01-02T12:00:00Z").getTime().toString(),
lastLoginAt: new Date("2024-01-02T12:00:00Z").getTime().toString(),
},
{
localId: "test-marian",
screenName: "marian",
email: "[email protected]",
emailVerified: true,
phoneNumber: "+19243007975",
displayName: "Marian Stone",
photoUrl: "https://randomuser.me/api/portraits/women/2.jpg",
rawPassword: "winter1",
createdAt: new Date("2024-01-03T12:00:00Z").getTime().toString(),
lastLoginAt: new Date("2024-01-03T12:00:00Z").getTime().toString(),
},
{
localId: "test-kurt",
screenName: "kurt",
email: "[email protected]",
emailVerified: true,
phoneNumber: "+19243007975",
displayName: "Kurt Howard",
photoUrl: "https://randomuser.me/api/portraits/men/23.jpg",
rawPassword: "mayday",
createdAt: new Date("2024-01-04T12:00:00Z").getTime().toString(),
lastLoginAt: new Date("2024-01-04T12:00:00Z").getTime().toString(),
},
{
localId: "test-dan",
screenName: "dan",
email: "[email protected]",
emailVerified: true,
phoneNumber: "+12046748092",
displayName: "Dan Day",
photoUrl: "https://randomuser.me/api/portraits/men/65.jpg",
rawPassword: "teresa",
createdAt: new Date("2024-01-05T12:00:00Z").getTime().toString(),
lastLoginAt: new Date("2024-01-05T12:00:00Z").getTime().toString(),
customAttributes: JSON.stringify({ admin: true }),
},
];

/**
* Seeds the Google Identity Platform (Firebase Auth) with test user accounts.
*
* @see https://randomuser.me/
* @see https://cloud.google.com/identity-platform
*/
export async function seed() {
const auth = new AuthPlus();
const { relyingparty } = identitytoolkit({ version: "v3", auth });
await relyingparty.uploadAccount({ requestBody: { users: testUsers } });
}
63 changes: 63 additions & 0 deletions db/seeds/02-workspaces.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/* SPDX-FileCopyrightText: 2014-present Kriasoft */
/* SPDX-License-Identifier: MIT */

import { Firestore, Timestamp } from "@google-cloud/firestore";
import { WorkspaceInput } from "../models";
import { testUsers as users } from "./01-users";

/**
* Test workspaces.
*/
export const testWorkspaces: (WorkspaceInput & { id: string })[] = [
{
id: "DwYchGFGpk",
ownerId: users[0].localId!,
name: "Personal workspace",
created: Timestamp.fromDate(new Date(+users[0].createdAt!)),
updated: Timestamp.fromDate(new Date(+users[0].createdAt!)),
archived: null,
},
{
id: "YfYKTcO9q9",
ownerId: users[1].localId!,
name: "Personal workspace",
created: Timestamp.fromDate(new Date(+users[1].createdAt!)),
updated: Timestamp.fromDate(new Date(+users[1].createdAt!)),
archived: null,
},
{
id: "c2OsmUvFMY",
ownerId: users[2].localId!,
name: "Personal workspace",
created: Timestamp.fromDate(new Date(+users[2].createdAt!)),
updated: Timestamp.fromDate(new Date(+users[2].createdAt!)),
archived: null,
},
{
id: "uTqcGw4qn7",
ownerId: users[3].localId!,
name: "Personal workspace",
created: Timestamp.fromDate(new Date(+users[3].createdAt!)),
updated: Timestamp.fromDate(new Date(+users[3].createdAt!)),
archived: null,
},
{
id: "vBHHgg5ydn",
ownerId: users[4].localId!,
name: "Personal workspace",
created: Timestamp.fromDate(new Date(+users[4].createdAt!)),
updated: Timestamp.fromDate(new Date(+users[4].createdAt!)),
archived: null,
},
];

export async function seed(db: Firestore) {
const batch = db.batch();

for (const { id, ...workspace } of testWorkspaces) {
const ref = db.doc(`workspace/${id}`);
batch.set(ref, workspace, { merge: true });
}

await batch.commit();
}
Loading