Skip to content

Commit e06835d

Browse files
committed
Add storage configuration and enhance file handling in API and web applications: Updated .env.example files to include storage settings, refactored ActiveUserDto to utilize dynamic storage URLs, and improved file service methods for better error handling and stability. Introduced AppDevtools for enhanced development experience in the web application.
1 parent b14a8a3 commit e06835d

File tree

13 files changed

+272
-203
lines changed

13 files changed

+272
-203
lines changed

apps/api/.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ MAIL_DOMAIN=example.com
1818

1919
# storage
2020
STORAGE_URL=http://localhost:9000
21+
STORAGE_BUCKET_NAME=public
2122
STORAGE_REGION=us-east-1
2223
STORAGE_ACCESS_KEY=admin
2324
STORAGE_SECRET_KEY=admin
Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
import { User } from 'better-auth';
22
import { Transform } from 'class-transformer';
33

4+
const STORAGE_URL =
5+
process.env['STORAGE_URL']?.replace(/\/+$/, '') ?? 'http://localhost:9000';
6+
const STORAGE_BUCKET = process.env['STORAGE_BUCKET_NAME'] ?? 'public';
7+
48
export class ActiveUserDto implements User {
59
id!: string;
610
name!: string;
711
emailVerified!: boolean;
812
email!: string;
913
createdAt!: Date;
1014
updatedAt!: Date;
11-
@Transform(({ value }) => (value ? `http://localhost:9000/dev/${value}` : null))
15+
@Transform(({ value }) =>
16+
value ? `${STORAGE_URL}/${STORAGE_BUCKET}/${value}` : null,
17+
)
1218
image?: string | null;
1319
}

apps/api/src/common/config/storage.config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ export class StorageConfig {
1515

1616
@IsString()
1717
@IsNotEmpty()
18-
bucketName = 'public';
18+
@Value('STORAGE_BUCKET_NAME', { default: 'public' })
19+
bucketName!: string;
1920

2021
@IsString()
2122
@IsNotEmpty()
Lines changed: 54 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,87 @@
1-
import { Injectable, NotFoundException } from '@nestjs/common';
2-
import { S3Service } from './s3.service';
3-
import { createId } from '@paralleldrive/cuid2';
4-
import { DrizzleTransactionClient } from 'src/databases/drizzle.provider';
5-
import { files } from 'src/databases/drizzle.schema';
6-
import { takeFirstOrThrow } from 'src/databases/drizzle.utils';
7-
import { eq } from 'drizzle-orm';
8-
import { StorageConfig } from 'src/common/config/storage.config';
9-
import { TransactionHost } from '@nestjs-cls/transactional';
1+
import { Injectable, NotFoundException } from "@nestjs/common";
2+
import { S3Service } from "./s3.service";
3+
import { createId } from "@paralleldrive/cuid2";
4+
import { DrizzleTransactionClient } from "src/databases/drizzle.provider";
5+
import { files } from "src/databases/drizzle.schema";
6+
import { takeFirstOrThrow } from "src/databases/drizzle.utils";
7+
import { eq } from "drizzle-orm";
8+
import { StorageConfig } from "src/common/config/storage.config";
9+
import { TransactionHost, Transactional } from "@nestjs-cls/transactional";
1010

1111
@Injectable()
1212
export class FilesService {
1313
constructor(
1414
private readonly s3Service: S3Service,
1515
private readonly txHost: TransactionHost<DrizzleTransactionClient>,
16-
private readonly storageConfig: StorageConfig,
16+
private readonly storageConfig: StorageConfig
1717
) {}
1818

19+
@Transactional()
1920
async create(file: Express.Multer.File) {
20-
const fileRecord = await this.txHost.tx
21-
.insert(files)
22-
.values({
23-
name: file.originalname,
24-
mimeType: file.mimetype,
25-
sizeBytes: file.size,
26-
storageKey: createId(),
27-
})
28-
.returning()
29-
.then(takeFirstOrThrow);
21+
const storageKey = createId();
22+
const test = "test";
3023

3124
await this.s3Service.putObject({
3225
bucketName: this.storageConfig.bucketName,
33-
key: fileRecord.storageKey,
34-
file: file.buffer,
26+
key: storageKey,
27+
file: file.buffer
3528
});
3629

37-
return fileRecord;
30+
try {
31+
return await this.txHost.tx
32+
.insert(files)
33+
.values({
34+
name: file.originalname,
35+
mimeType: file.mimetype,
36+
sizeBytes: file.size,
37+
storageKey
38+
})
39+
.returning()
40+
.then(takeFirstOrThrow);
41+
} catch (error) {
42+
// Compensate uploaded object if the DB write fails.
43+
await this.s3Service.deleteObject({
44+
bucketName: this.storageConfig.bucketName,
45+
key: storageKey
46+
});
47+
throw error;
48+
}
3849
}
3950

51+
@Transactional()
4052
async update(key: string, file: Express.Multer.File) {
4153
const fileRecord = await this.txHost.tx.query.files.findFirst({
4254
where: {
43-
storageKey: key,
44-
},
55+
storageKey: key
56+
}
4557
});
46-
if (!fileRecord) throw new NotFoundException('File not found');
58+
if (!fileRecord) throw new NotFoundException("File not found");
4759

48-
// Delete old file record and S3 object
49-
await this.txHost.tx.delete(files).where(eq(files.storageKey, key));
50-
await this.s3Service.deleteObject({
60+
// Keep the storage key stable and replace object contents/metadata in-place.
61+
await this.s3Service.putObject({
5162
bucketName: this.storageConfig.bucketName,
5263
key,
64+
file: file.buffer
5365
});
5466

55-
// Create new file record and upload to S3
56-
return this.create(file);
67+
return this.txHost.tx
68+
.update(files)
69+
.set({
70+
name: file.originalname,
71+
mimeType: file.mimetype,
72+
sizeBytes: file.size
73+
})
74+
.where(eq(files.storageKey, key))
75+
.returning()
76+
.then(takeFirstOrThrow);
5777
}
5878

79+
@Transactional()
5980
async delete(key: string) {
60-
await this.txHost.tx.delete(files).where(eq(files.storageKey, key));
6181
await this.s3Service.deleteObject({
6282
bucketName: this.storageConfig.bucketName,
63-
key,
83+
key
6484
});
85+
await this.txHost.tx.delete(files).where(eq(files.storageKey, key));
6586
}
6687
}

apps/api/src/utils/openapi.ts

Lines changed: 50 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,73 +1,65 @@
1-
// @ts-nocheck
2-
import { OpenAPIObject } from '@nestjs/swagger';
3-
import chalk from 'chalk';
4-
import * as fsSync from 'node:fs';
5-
import * as fs from 'node:fs/promises';
6-
import openapiTS, { astToString, type OpenAPI3 } from 'openapi-typescript';
7-
import * as ts from 'typescript';
8-
9-
const DEFAULT_OUTPUT_DIR = './generated';
10-
const DEFAULT_FILENAME = 'openapi.d.ts';
11-
12-
interface OpenApiSpecBase {
13-
fileName?: string;
14-
}
1+
import { OpenAPIObject } from "@nestjs/swagger";
2+
import chalk from "chalk";
3+
import * as fsSync from "node:fs";
4+
import * as fs from "node:fs/promises";
5+
import openapiTS from "openapi-typescript";
6+
import * as ts from "typescript";
157

16-
interface OpenApiSpecApiOpenAPI3 extends OpenApiSpecBase {
17-
document: OpenAPI3;
18-
}
8+
const DEFAULT_OUTPUT_DIR = "./generated";
9+
const DEFAULT_FILENAME = "openapi.d.ts";
1910

20-
interface OpenApiSpecApiObject extends OpenApiSpecBase {
11+
interface OpenApiSpec {
2112
document: OpenAPIObject;
13+
fileName?: string;
2214
}
2315

24-
type OpenApiSpec = OpenApiSpecBase &
25-
(OpenApiSpecApiOpenAPI3 | OpenApiSpecApiObject);
16+
type TransformableSchema = {
17+
format?: string;
18+
nullable?: boolean;
19+
};
2620

2721
export async function generateOpenApiSpecs(
28-
specs: OpenApiSpec[],
22+
specs: OpenApiSpec[]
2923
): Promise<void> {
3024
if (specs.length === 0) {
31-
console.log(chalk.yellow('⚠️ No OpenAPI specs provided'));
25+
console.log(chalk.yellow("⚠️ No OpenAPI specs provided"));
3226
return;
3327
}
3428

3529
console.log(chalk.blue(`🔄 Generating ${specs.length} OpenAPI spec(s)...`));
3630

3731
// Generate all specs in parallel
3832
const results = await Promise.allSettled(
39-
specs.map((spec) => generateSingleSpec(spec)),
33+
specs.map(spec => generateSingleSpec(spec))
4034
);
4135

4236
// Process results
4337
const successful = results.filter(
44-
(result) => result.status === 'fulfilled',
45-
).length;
46-
const failed = results.filter(
47-
(result) => result.status === 'rejected',
38+
result => result.status === "fulfilled"
4839
).length;
40+
const failed = results.filter(result => result.status === "rejected").length;
4941

5042
if (failed > 0) {
5143
console.log(chalk.yellow(`⚠️ ${failed} spec(s) failed to generate`));
5244
results.forEach((result, index) => {
53-
if (result.status === 'rejected') {
45+
if (result.status === "rejected") {
5446
console.error(
5547
chalk.red(
56-
`❌ Spec ${index + 1} (${specs[index]?.fileName || DEFAULT_FILENAME}): ${result.reason}`,
57-
),
48+
`❌ Spec ${index + 1} (${specs[index]?.fileName || DEFAULT_FILENAME}): ${result.reason}`
49+
)
5850
);
5951
}
6052
});
6153
}
6254

6355
if (successful > 0) {
6456
console.log(
65-
chalk.green(`✅ ${successful} OpenAPI spec(s) generated successfully`),
57+
chalk.green(`✅ ${successful} OpenAPI spec(s) generated successfully`)
6658
);
6759
}
6860

6961
if (failed === specs.length) {
70-
throw new Error('All OpenAPI specs failed to generate');
62+
throw new Error("All OpenAPI specs failed to generate");
7163
}
7264
}
7365

@@ -76,23 +68,33 @@ async function generateSingleSpec(spec: OpenApiSpec): Promise<void> {
7668
const filePath = `${DEFAULT_OUTPUT_DIR}/${fileName}`;
7769

7870
const BLOB = ts.factory.createTypeReferenceNode(
79-
ts.factory.createIdentifier('Blob'),
71+
ts.factory.createIdentifier("Blob")
8072
); // `Blob`
8173
const NULL = ts.factory.createLiteralTypeNode(ts.factory.createNull()); // `null`
8274

8375
try {
8476
// Generate new OpenAPI types content
85-
const ast = await openapiTS(spec.document as OpenAPI3, {
86-
transform(schemaObject) {
87-
if (schemaObject.format === 'binary') {
88-
return schemaObject.nullable
89-
? ts.factory.createUnionTypeNode([BLOB, NULL])
90-
: BLOB;
77+
const ast = await openapiTS(
78+
spec.document as Parameters<typeof openapiTS>[0],
79+
{
80+
transform(schemaObject: TransformableSchema) {
81+
if (schemaObject.format === "binary") {
82+
return schemaObject.nullable
83+
? ts.factory.createUnionTypeNode([BLOB, NULL])
84+
: BLOB;
85+
}
86+
return undefined; // Use default transformation for other schema objects
9187
}
92-
return undefined; // Use default transformation for other schema objects
93-
},
94-
});
95-
const newContents = astToString(ast) as string;
88+
}
89+
);
90+
const sourceFile = ts.factory.createSourceFile(
91+
ast as ts.Statement[],
92+
ts.factory.createToken(ts.SyntaxKind.EndOfFileToken),
93+
ts.NodeFlags.None
94+
);
95+
const newContents = ts
96+
.createPrinter({ newLine: ts.NewLineKind.LineFeed })
97+
.printFile(sourceFile);
9698

9799
// Check if content has changed
98100
const hasChanged = await hasContentChanged(filePath, newContents);
@@ -105,21 +107,21 @@ async function generateSingleSpec(spec: OpenApiSpec): Promise<void> {
105107
} catch (error) {
106108
const errorMessage = error instanceof Error ? error.message : String(error);
107109
throw new Error(`Failed to generate ${fileName}: ${errorMessage}`, {
108-
cause: error,
110+
cause: error
109111
});
110112
}
111113
}
112114

113115
async function hasContentChanged(
114116
filePath: string,
115-
newContents: string,
117+
newContents: string
116118
): Promise<boolean> {
117119
if (!fsSync.existsSync(filePath)) {
118120
return true;
119121
}
120122

121123
try {
122-
const existingContents = await fs.readFile(filePath, 'utf8');
124+
const existingContents = await fs.readFile(filePath, "utf8");
123125
return existingContents !== newContents;
124126
} catch {
125127
return true;
@@ -128,13 +130,13 @@ async function hasContentChanged(
128130

129131
async function writeOpenApiFile(
130132
filePath: string,
131-
contents: string,
133+
contents: string
132134
): Promise<void> {
133135
// Ensure directory exists
134136
await fs.mkdir(DEFAULT_OUTPUT_DIR, { recursive: true });
135137

136138
// Write file
137-
await fs.writeFile(filePath, contents, 'utf8');
139+
await fs.writeFile(filePath, contents, "utf8");
138140
}
139141

140142
// Convenience function for single spec (backward compatibility)

apps/web/.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
VITE_API_URL=http://localhost:8000
22
VITE_WEB_URL=http://localhost:3000
3+
VITE_STORAGE_URL=http://localhost:9001

apps/web/src/components/otp-form.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ export function OTPForm({ email, ...props }: OTPFormProps) {
2222
const verifyOtpMutation = useMutation({
2323
mutationFn: async (otp: string) => api.auth.signInWithOtp(email, otp),
2424
onSuccess: async () => {
25-
await queryClient.invalidateQueries();
25+
await queryClient.invalidateQueries({
26+
queryKey: [...api.auth.queryKeys, 'session'],
27+
});
2628
await router.invalidate();
2729
await navigate({ to: '/' });
2830
},

apps/web/src/lib/api-client.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@ type FetchImpl = (input: RequestInfo | URL, init?: RequestInit) => Promise<Respo
88

99
let browserClient: ApiClient | undefined;
1010

11+
function getRequiredApiUrl() {
12+
const apiUrl = import.meta.env.VITE_API_URL;
13+
if (!apiUrl) {
14+
throw new Error('VITE_API_URL is required');
15+
}
16+
return apiUrl;
17+
}
18+
1119
function toHeaderObject(headers?: HeadersInit) {
1220
if (!headers) return undefined;
1321
if (headers instanceof Headers) return Object.fromEntries(headers.entries());
@@ -47,7 +55,7 @@ const clientFetch: FetchImpl = (input, init) =>
4755

4856
function initApiClient(opts?: { headers?: HeadersInit; fetch?: FetchImpl }) {
4957
const client = createClient<paths>({
50-
baseUrl: String(import.meta.env.VITE_API_URL),
58+
baseUrl: getRequiredApiUrl(),
5159
credentials: 'include',
5260
headers: opts?.headers,
5361
fetch: opts?.fetch,

0 commit comments

Comments
 (0)