ar-saas 0.1.0
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/README.md +62 -0
- package/dist/cli.js +67 -0
- package/dist/generator.js +242 -0
- package/dist/index.js +13 -0
- package/dist/license.js +71 -0
- package/package.json +46 -0
- package/templates/backend/.env.example +67 -0
- package/templates/backend/.prettierrc +4 -0
- package/templates/backend/README.md +168 -0
- package/templates/backend/eslint.config.mjs +35 -0
- package/templates/backend/nest-cli.json +8 -0
- package/templates/backend/package-lock.json +10979 -0
- package/templates/backend/package.json +88 -0
- package/templates/backend/src/app.controller.spec.ts +24 -0
- package/templates/backend/src/app.controller.ts +15 -0
- package/templates/backend/src/app.module.ts +40 -0
- package/templates/backend/src/app.service.ts +11 -0
- package/templates/backend/src/common/base/base.repository.ts +221 -0
- package/templates/backend/src/common/base/base.schema.ts +24 -0
- package/templates/backend/src/common/decorators/cookie.decorator.ts +9 -0
- package/templates/backend/src/common/decorators/current-user.decorator.ts +20 -0
- package/templates/backend/src/common/decorators/workspace-id.decorator.ts +14 -0
- package/templates/backend/src/common/filters/global-exception.filter.ts +61 -0
- package/templates/backend/src/common/guards/jwt-auth.guard.ts +5 -0
- package/templates/backend/src/common/interceptors/workspace-tenant.interceptor.ts +45 -0
- package/templates/backend/src/main.ts +51 -0
- package/templates/backend/src/modules/auth/auth.controller.ts +158 -0
- package/templates/backend/src/modules/auth/auth.module.ts +20 -0
- package/templates/backend/src/modules/auth/auth.service.ts +257 -0
- package/templates/backend/src/modules/auth/dto/forgot-password.dto.ts +9 -0
- package/templates/backend/src/modules/auth/dto/login.dto.ts +14 -0
- package/templates/backend/src/modules/auth/dto/refresh-token.dto.ts +12 -0
- package/templates/backend/src/modules/auth/dto/register.dto.ts +26 -0
- package/templates/backend/src/modules/auth/dto/reset-password.dto.ts +16 -0
- package/templates/backend/src/modules/auth/dto/verify-email.dto.ts +9 -0
- package/templates/backend/src/modules/auth/strategies/jwt.strategy.ts +43 -0
- package/templates/backend/src/modules/mail/mail.module.ts +9 -0
- package/templates/backend/src/modules/mail/mail.service.ts +141 -0
- package/templates/backend/src/modules/users/schemas/user.schema.ts +54 -0
- package/templates/backend/src/modules/users/users.module.ts +14 -0
- package/templates/backend/src/modules/users/users.repository.ts +51 -0
- package/templates/backend/src/modules/users/users.service.ts +104 -0
- package/templates/backend/src/modules/workspaces/schemas/workspace.schema.ts +26 -0
- package/templates/backend/src/modules/workspaces/workspaces.module.ts +16 -0
- package/templates/backend/src/modules/workspaces/workspaces.repository.ts +34 -0
- package/templates/backend/src/modules/workspaces/workspaces.service.ts +42 -0
- package/templates/backend/test/app.e2e-spec.ts +25 -0
- package/templates/backend/test/jest-e2e.json +9 -0
- package/templates/backend/tsconfig.build.json +4 -0
- package/templates/backend/tsconfig.json +26 -0
- package/templates/frontend/.env.local.example +1 -0
- package/templates/frontend/components.json +20 -0
- package/templates/frontend/eslint.config.mjs +14 -0
- package/templates/frontend/next.config.ts +5 -0
- package/templates/frontend/package-lock.json +6722 -0
- package/templates/frontend/package.json +40 -0
- package/templates/frontend/postcss.config.mjs +7 -0
- package/templates/frontend/src/app/(auth)/forgot-password/page.tsx +84 -0
- package/templates/frontend/src/app/(auth)/layout.tsx +28 -0
- package/templates/frontend/src/app/(auth)/login/page.tsx +111 -0
- package/templates/frontend/src/app/(auth)/register/page.tsx +119 -0
- package/templates/frontend/src/app/(auth)/reset-password/page.tsx +120 -0
- package/templates/frontend/src/app/(auth)/verify-email/page.tsx +78 -0
- package/templates/frontend/src/app/(dashboard)/dashboard/page.tsx +36 -0
- package/templates/frontend/src/app/(dashboard)/layout.tsx +59 -0
- package/templates/frontend/src/app/globals.css +81 -0
- package/templates/frontend/src/app/layout.tsx +26 -0
- package/templates/frontend/src/app/page.tsx +5 -0
- package/templates/frontend/src/app/setup/page.tsx +278 -0
- package/templates/frontend/src/components/ui/button.tsx +52 -0
- package/templates/frontend/src/components/ui/card.tsx +50 -0
- package/templates/frontend/src/components/ui/form.tsx +158 -0
- package/templates/frontend/src/components/ui/input.tsx +21 -0
- package/templates/frontend/src/components/ui/label.tsx +22 -0
- package/templates/frontend/src/components/ui/toast.tsx +109 -0
- package/templates/frontend/src/components/ui/toaster.tsx +30 -0
- package/templates/frontend/src/hooks/use-toast.ts +116 -0
- package/templates/frontend/src/lib/api/auth.ts +39 -0
- package/templates/frontend/src/lib/api/client.ts +66 -0
- package/templates/frontend/src/lib/hooks/use-auth.ts +1 -0
- package/templates/frontend/src/lib/utils.ts +6 -0
- package/templates/frontend/src/providers/auth-provider.tsx +60 -0
- package/templates/frontend/src/types/api.ts +12 -0
- package/templates/frontend/src/types/auth.ts +27 -0
- package/templates/frontend/tsconfig.json +23 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { Injectable, Logger } from '@nestjs/common';
|
|
2
|
+
import { ConfigService } from '@nestjs/config';
|
|
3
|
+
import { Resend } from 'resend';
|
|
4
|
+
|
|
5
|
+
export interface SendMailOptions {
|
|
6
|
+
to: string | string[];
|
|
7
|
+
subject: string;
|
|
8
|
+
html: string;
|
|
9
|
+
from?: string;
|
|
10
|
+
replyTo?: string;
|
|
11
|
+
attachments?: Array<{
|
|
12
|
+
filename: string;
|
|
13
|
+
content: Buffer | string;
|
|
14
|
+
contentType?: string;
|
|
15
|
+
}>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
@Injectable()
|
|
19
|
+
export class MailService {
|
|
20
|
+
private readonly resend: Resend;
|
|
21
|
+
private readonly logger = new Logger(MailService.name);
|
|
22
|
+
private readonly fromEmail: string;
|
|
23
|
+
private readonly fromName: string;
|
|
24
|
+
|
|
25
|
+
constructor(private readonly configService: ConfigService) {
|
|
26
|
+
this.resend = new Resend(
|
|
27
|
+
this.configService.getOrThrow<string>('RESEND_API_KEY'),
|
|
28
|
+
);
|
|
29
|
+
this.fromName = this.configService.get<string>('RESEND_FROM_NAME', 'SaaS AR');
|
|
30
|
+
this.fromEmail = this.configService.getOrThrow<string>('RESEND_FROM_EMAIL');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async send(options: SendMailOptions): Promise<{ id: string }> {
|
|
34
|
+
try {
|
|
35
|
+
const { data, error } = await this.resend.emails.send({
|
|
36
|
+
from: options.from ?? `${this.fromName} <${this.fromEmail}>`,
|
|
37
|
+
to: Array.isArray(options.to) ? options.to : [options.to],
|
|
38
|
+
subject: options.subject,
|
|
39
|
+
html: options.html,
|
|
40
|
+
replyTo: options.replyTo,
|
|
41
|
+
attachments: options.attachments?.map((att) => ({
|
|
42
|
+
filename: att.filename,
|
|
43
|
+
content: att.content,
|
|
44
|
+
})),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
if (error) {
|
|
48
|
+
this.logger.error(`Error al enviar email a ${options.to}: ${error.message}`);
|
|
49
|
+
return { id: '' };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
this.logger.log(`Email enviado a ${options.to} [id=${data?.id}]`);
|
|
53
|
+
return { id: data!.id };
|
|
54
|
+
} catch (err) {
|
|
55
|
+
this.logger.error(
|
|
56
|
+
`Excepción al enviar email a ${options.to}`,
|
|
57
|
+
err instanceof Error ? err.stack : String(err),
|
|
58
|
+
);
|
|
59
|
+
return { id: '' };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async sendVerificationEmail(
|
|
64
|
+
to: string,
|
|
65
|
+
name: string,
|
|
66
|
+
token: string,
|
|
67
|
+
): Promise<{ id: string }> {
|
|
68
|
+
const appUrl = this.configService.getOrThrow<string>('APP_URL');
|
|
69
|
+
const link = `${appUrl}/auth/verify-email?token=${token}`;
|
|
70
|
+
|
|
71
|
+
return this.send({
|
|
72
|
+
to,
|
|
73
|
+
subject: 'Verificá tu email',
|
|
74
|
+
html: `
|
|
75
|
+
<!DOCTYPE html>
|
|
76
|
+
<html>
|
|
77
|
+
<body style="font-family:sans-serif;max-width:600px;margin:0 auto;padding:24px">
|
|
78
|
+
<h2>Hola ${name},</h2>
|
|
79
|
+
<p>Gracias por registrarte. Hacé click en el siguiente botón para verificar tu email:</p>
|
|
80
|
+
<p style="margin:32px 0">
|
|
81
|
+
<a href="${link}"
|
|
82
|
+
style="background:#000;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;font-weight:bold">
|
|
83
|
+
Verificar email
|
|
84
|
+
</a>
|
|
85
|
+
</p>
|
|
86
|
+
<p style="color:#666;font-size:14px">Este link expira en 24 horas.</p>
|
|
87
|
+
<p style="color:#666;font-size:14px">Si no creaste esta cuenta, ignorá este mensaje.</p>
|
|
88
|
+
</body>
|
|
89
|
+
</html>
|
|
90
|
+
`,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async sendPasswordResetEmail(
|
|
95
|
+
to: string,
|
|
96
|
+
name: string,
|
|
97
|
+
token: string,
|
|
98
|
+
): Promise<{ id: string }> {
|
|
99
|
+
const appUrl = this.configService.getOrThrow<string>('APP_URL');
|
|
100
|
+
const link = `${appUrl}/auth/reset-password?token=${token}`;
|
|
101
|
+
|
|
102
|
+
return this.send({
|
|
103
|
+
to,
|
|
104
|
+
subject: 'Restablecé tu contraseña',
|
|
105
|
+
html: `
|
|
106
|
+
<!DOCTYPE html>
|
|
107
|
+
<html>
|
|
108
|
+
<body style="font-family:sans-serif;max-width:600px;margin:0 auto;padding:24px">
|
|
109
|
+
<h2>Hola ${name},</h2>
|
|
110
|
+
<p>Recibimos una solicitud para restablecer tu contraseña.</p>
|
|
111
|
+
<p style="margin:32px 0">
|
|
112
|
+
<a href="${link}"
|
|
113
|
+
style="background:#000;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;font-weight:bold">
|
|
114
|
+
Restablecer contraseña
|
|
115
|
+
</a>
|
|
116
|
+
</p>
|
|
117
|
+
<p style="color:#666;font-size:14px">Este link expira en 1 hora.</p>
|
|
118
|
+
<p style="color:#666;font-size:14px">Si no solicitaste esto, ignorá este mensaje.</p>
|
|
119
|
+
</body>
|
|
120
|
+
</html>
|
|
121
|
+
`,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async sendWelcomeEmail(to: string, name: string): Promise<{ id: string }> {
|
|
126
|
+
return this.send({
|
|
127
|
+
to,
|
|
128
|
+
subject: `¡Bienvenido/a a ${this.fromName}!`,
|
|
129
|
+
html: `
|
|
130
|
+
<!DOCTYPE html>
|
|
131
|
+
<html>
|
|
132
|
+
<body style="font-family:sans-serif;max-width:600px;margin:0 auto;padding:24px">
|
|
133
|
+
<h2>¡Hola ${name}!</h2>
|
|
134
|
+
<p>Tu cuenta fue verificada exitosamente. Ya podés iniciar sesión y empezar a usar la plataforma.</p>
|
|
135
|
+
<p style="color:#666;font-size:14px">Si tenés alguna duda, respondé este email.</p>
|
|
136
|
+
</body>
|
|
137
|
+
</html>
|
|
138
|
+
`,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
|
2
|
+
import { HydratedDocument } from 'mongoose';
|
|
3
|
+
|
|
4
|
+
export type UserDocument = HydratedDocument<User>;
|
|
5
|
+
|
|
6
|
+
export enum UserRole {
|
|
7
|
+
OWNER = 'owner',
|
|
8
|
+
ADMIN = 'admin',
|
|
9
|
+
MEMBER = 'member',
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
@Schema({ collection: 'users', timestamps: true })
|
|
13
|
+
export class User {
|
|
14
|
+
@Prop({ required: true, trim: true })
|
|
15
|
+
name!: string;
|
|
16
|
+
|
|
17
|
+
@Prop({ required: true, unique: true, lowercase: true, trim: true })
|
|
18
|
+
email!: string;
|
|
19
|
+
|
|
20
|
+
@Prop({ type: String, default: null, select: false })
|
|
21
|
+
password!: string | null;
|
|
22
|
+
|
|
23
|
+
@Prop({ required: true })
|
|
24
|
+
workspaceId!: string;
|
|
25
|
+
|
|
26
|
+
@Prop({ default: false })
|
|
27
|
+
emailVerified!: boolean;
|
|
28
|
+
|
|
29
|
+
@Prop({ type: String, default: null, select: false })
|
|
30
|
+
emailVerificationToken!: string | null;
|
|
31
|
+
|
|
32
|
+
@Prop({ type: Date, default: null })
|
|
33
|
+
emailVerificationTokenExpiresAt!: Date | null;
|
|
34
|
+
|
|
35
|
+
@Prop({ type: String, default: null, select: false })
|
|
36
|
+
passwordResetToken!: string | null;
|
|
37
|
+
|
|
38
|
+
@Prop({ type: Date, default: null })
|
|
39
|
+
passwordResetTokenExpiresAt!: Date | null;
|
|
40
|
+
|
|
41
|
+
@Prop({ type: String, default: null, select: false })
|
|
42
|
+
refreshToken!: string | null;
|
|
43
|
+
|
|
44
|
+
@Prop({ type: String, enum: Object.values(UserRole), default: UserRole.OWNER })
|
|
45
|
+
role!: UserRole;
|
|
46
|
+
|
|
47
|
+
@Prop({ type: Date, default: null })
|
|
48
|
+
lastLoginAt!: Date | null;
|
|
49
|
+
|
|
50
|
+
createdAt!: Date;
|
|
51
|
+
updatedAt!: Date;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const UserSchema = SchemaFactory.createForClass(User);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Module } from '@nestjs/common';
|
|
2
|
+
import { MongooseModule } from '@nestjs/mongoose';
|
|
3
|
+
import { User, UserSchema } from './schemas/user.schema';
|
|
4
|
+
import { UsersRepository } from './users.repository';
|
|
5
|
+
import { UsersService } from './users.service';
|
|
6
|
+
|
|
7
|
+
@Module({
|
|
8
|
+
imports: [
|
|
9
|
+
MongooseModule.forFeature([{ name: User.name, schema: UserSchema }]),
|
|
10
|
+
],
|
|
11
|
+
providers: [UsersService, UsersRepository],
|
|
12
|
+
exports: [UsersService],
|
|
13
|
+
})
|
|
14
|
+
export class UsersModule {}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
import { InjectModel } from '@nestjs/mongoose';
|
|
3
|
+
import { Model } from 'mongoose';
|
|
4
|
+
import { User, UserDocument } from './schemas/user.schema';
|
|
5
|
+
|
|
6
|
+
@Injectable()
|
|
7
|
+
export class UsersRepository {
|
|
8
|
+
constructor(
|
|
9
|
+
@InjectModel(User.name) private readonly userModel: Model<UserDocument>,
|
|
10
|
+
) {}
|
|
11
|
+
|
|
12
|
+
async findByEmail(email: string): Promise<UserDocument | null> {
|
|
13
|
+
return this.userModel
|
|
14
|
+
.findOne({ email })
|
|
15
|
+
.select('+password +refreshToken')
|
|
16
|
+
.exec();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async findById(id: string): Promise<UserDocument | null> {
|
|
20
|
+
return this.userModel.findById(id).exec();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async findByIdWithSecrets(id: string): Promise<UserDocument | null> {
|
|
24
|
+
return this.userModel.findById(id).select('+refreshToken').exec();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async findByVerificationToken(token: string): Promise<UserDocument | null> {
|
|
28
|
+
return this.userModel
|
|
29
|
+
.findOne({ emailVerificationToken: token })
|
|
30
|
+
.select('+emailVerificationToken')
|
|
31
|
+
.exec();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async findByPasswordResetToken(token: string): Promise<UserDocument | null> {
|
|
35
|
+
return this.userModel
|
|
36
|
+
.findOne({ passwordResetToken: token })
|
|
37
|
+
.select('+passwordResetToken')
|
|
38
|
+
.exec();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async create(data: Partial<User>): Promise<UserDocument> {
|
|
42
|
+
const user = new this.userModel(data);
|
|
43
|
+
return user.save();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async update(id: string, data: Partial<User>): Promise<UserDocument | null> {
|
|
47
|
+
return this.userModel
|
|
48
|
+
.findByIdAndUpdate(id, { $set: data }, { returnDocument: 'after' })
|
|
49
|
+
.exec();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { ConflictException, Injectable } from '@nestjs/common';
|
|
2
|
+
import * as bcrypt from 'bcryptjs';
|
|
3
|
+
import { User, UserDocument, UserRole } from './schemas/user.schema';
|
|
4
|
+
import { UsersRepository } from './users.repository';
|
|
5
|
+
|
|
6
|
+
@Injectable()
|
|
7
|
+
export class UsersService {
|
|
8
|
+
constructor(private readonly usersRepository: UsersRepository) {}
|
|
9
|
+
|
|
10
|
+
async create(data: {
|
|
11
|
+
name: string;
|
|
12
|
+
email: string;
|
|
13
|
+
password: string;
|
|
14
|
+
workspaceId: string;
|
|
15
|
+
role?: UserRole;
|
|
16
|
+
}): Promise<UserDocument> {
|
|
17
|
+
const existing = await this.usersRepository.findByEmail(data.email);
|
|
18
|
+
if (existing) {
|
|
19
|
+
throw new ConflictException('Este email ya está registrado.');
|
|
20
|
+
}
|
|
21
|
+
const hashedPassword = await bcrypt.hash(data.password, 12);
|
|
22
|
+
return this.usersRepository.create({ ...data, password: hashedPassword });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async findByEmail(email: string): Promise<UserDocument | null> {
|
|
26
|
+
return this.usersRepository.findByEmail(email);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async findById(id: string): Promise<UserDocument | null> {
|
|
30
|
+
return this.usersRepository.findById(id);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async updateRefreshToken(
|
|
34
|
+
userId: string,
|
|
35
|
+
refreshToken: string | null,
|
|
36
|
+
): Promise<void> {
|
|
37
|
+
const hashed = refreshToken
|
|
38
|
+
? await bcrypt.hash(refreshToken, 10)
|
|
39
|
+
: null;
|
|
40
|
+
await this.usersRepository.update(userId, { refreshToken: hashed });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async validateRefreshToken(
|
|
44
|
+
userId: string,
|
|
45
|
+
token: string,
|
|
46
|
+
): Promise<UserDocument | null> {
|
|
47
|
+
const user = await this.usersRepository.findByIdWithSecrets(userId);
|
|
48
|
+
if (!user?.refreshToken) return null;
|
|
49
|
+
const valid = await bcrypt.compare(token, user.refreshToken);
|
|
50
|
+
return valid ? user : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async setEmailVerificationToken(
|
|
54
|
+
userId: string,
|
|
55
|
+
token: string,
|
|
56
|
+
expiresAt: Date,
|
|
57
|
+
): Promise<void> {
|
|
58
|
+
await this.usersRepository.update(userId, {
|
|
59
|
+
emailVerificationToken: token,
|
|
60
|
+
emailVerificationTokenExpiresAt: expiresAt,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async findByVerificationToken(token: string): Promise<UserDocument | null> {
|
|
65
|
+
return this.usersRepository.findByVerificationToken(token);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async markEmailVerified(userId: string): Promise<void> {
|
|
69
|
+
await this.usersRepository.update(userId, {
|
|
70
|
+
emailVerified: true,
|
|
71
|
+
emailVerificationToken: null,
|
|
72
|
+
emailVerificationTokenExpiresAt: null,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async setPasswordResetToken(
|
|
77
|
+
userId: string,
|
|
78
|
+
token: string,
|
|
79
|
+
expiresAt: Date,
|
|
80
|
+
): Promise<void> {
|
|
81
|
+
await this.usersRepository.update(userId, {
|
|
82
|
+
passwordResetToken: token,
|
|
83
|
+
passwordResetTokenExpiresAt: expiresAt,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async findByPasswordResetToken(token: string): Promise<UserDocument | null> {
|
|
88
|
+
return this.usersRepository.findByPasswordResetToken(token);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async updatePassword(userId: string, password: string): Promise<void> {
|
|
92
|
+
const hashed = await bcrypt.hash(password, 12);
|
|
93
|
+
await this.usersRepository.update(userId, {
|
|
94
|
+
password: hashed,
|
|
95
|
+
passwordResetToken: null,
|
|
96
|
+
passwordResetTokenExpiresAt: null,
|
|
97
|
+
refreshToken: null,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async updateLastLoginAt(userId: string): Promise<void> {
|
|
102
|
+
await this.usersRepository.update(userId, { lastLoginAt: new Date() });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
|
2
|
+
import { HydratedDocument } from 'mongoose';
|
|
3
|
+
|
|
4
|
+
export type WorkspaceDocument = HydratedDocument<Workspace>;
|
|
5
|
+
|
|
6
|
+
@Schema({ collection: 'workspaces', timestamps: true })
|
|
7
|
+
export class Workspace {
|
|
8
|
+
@Prop({ required: true, trim: true })
|
|
9
|
+
name!: string;
|
|
10
|
+
|
|
11
|
+
@Prop({ required: true, unique: true, lowercase: true, trim: true })
|
|
12
|
+
slug!: string;
|
|
13
|
+
|
|
14
|
+
@Prop({ required: true })
|
|
15
|
+
ownerId!: string;
|
|
16
|
+
|
|
17
|
+
@Prop({ type: String, enum: ['active', 'suspended'], default: 'active' })
|
|
18
|
+
status!: 'active' | 'suspended';
|
|
19
|
+
|
|
20
|
+
createdAt!: Date;
|
|
21
|
+
updatedAt!: Date;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const WorkspaceSchema = SchemaFactory.createForClass(Workspace);
|
|
25
|
+
|
|
26
|
+
WorkspaceSchema.index({ ownerId: 1 });
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Module } from '@nestjs/common';
|
|
2
|
+
import { MongooseModule } from '@nestjs/mongoose';
|
|
3
|
+
import { Workspace, WorkspaceSchema } from './schemas/workspace.schema';
|
|
4
|
+
import { WorkspacesRepository } from './workspaces.repository';
|
|
5
|
+
import { WorkspacesService } from './workspaces.service';
|
|
6
|
+
|
|
7
|
+
@Module({
|
|
8
|
+
imports: [
|
|
9
|
+
MongooseModule.forFeature([
|
|
10
|
+
{ name: Workspace.name, schema: WorkspaceSchema },
|
|
11
|
+
]),
|
|
12
|
+
],
|
|
13
|
+
providers: [WorkspacesService, WorkspacesRepository],
|
|
14
|
+
exports: [WorkspacesService],
|
|
15
|
+
})
|
|
16
|
+
export class WorkspacesModule {}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
import { InjectModel } from '@nestjs/mongoose';
|
|
3
|
+
import { Model } from 'mongoose';
|
|
4
|
+
import { Workspace, WorkspaceDocument } from './schemas/workspace.schema';
|
|
5
|
+
|
|
6
|
+
@Injectable()
|
|
7
|
+
export class WorkspacesRepository {
|
|
8
|
+
constructor(
|
|
9
|
+
@InjectModel(Workspace.name)
|
|
10
|
+
private readonly workspaceModel: Model<WorkspaceDocument>,
|
|
11
|
+
) {}
|
|
12
|
+
|
|
13
|
+
async findById(id: string): Promise<WorkspaceDocument | null> {
|
|
14
|
+
return this.workspaceModel.findById(id).exec();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async findBySlug(slug: string): Promise<WorkspaceDocument | null> {
|
|
18
|
+
return this.workspaceModel.findOne({ slug }).exec();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async create(data: Partial<Workspace>): Promise<WorkspaceDocument> {
|
|
22
|
+
const workspace = new this.workspaceModel(data);
|
|
23
|
+
return workspace.save();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async update(
|
|
27
|
+
id: string,
|
|
28
|
+
data: Partial<Workspace>,
|
|
29
|
+
): Promise<WorkspaceDocument | null> {
|
|
30
|
+
return this.workspaceModel
|
|
31
|
+
.findByIdAndUpdate(id, { $set: data }, { returnDocument: 'after' })
|
|
32
|
+
.exec();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
import { Workspace, WorkspaceDocument } from './schemas/workspace.schema';
|
|
3
|
+
import { WorkspacesRepository } from './workspaces.repository';
|
|
4
|
+
|
|
5
|
+
@Injectable()
|
|
6
|
+
export class WorkspacesService {
|
|
7
|
+
constructor(
|
|
8
|
+
private readonly workspacesRepository: WorkspacesRepository,
|
|
9
|
+
) {}
|
|
10
|
+
|
|
11
|
+
async create(ownerId: string, name: string): Promise<WorkspaceDocument> {
|
|
12
|
+
const slug = await this.generateUniqueSlug(name);
|
|
13
|
+
return this.workspacesRepository.create({ name, slug, ownerId });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async update(
|
|
17
|
+
id: string,
|
|
18
|
+
data: Partial<Workspace>,
|
|
19
|
+
): Promise<WorkspaceDocument | null> {
|
|
20
|
+
return this.workspacesRepository.update(id, data);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async findById(id: string): Promise<WorkspaceDocument | null> {
|
|
24
|
+
return this.workspacesRepository.findById(id);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
private async generateUniqueSlug(name: string): Promise<string> {
|
|
28
|
+
const base = name
|
|
29
|
+
.toLowerCase()
|
|
30
|
+
.normalize('NFD')
|
|
31
|
+
.replace(/\p{M}/gu, '')
|
|
32
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
33
|
+
.replace(/^-+|-+$/g, '');
|
|
34
|
+
|
|
35
|
+
let slug = base || 'workspace';
|
|
36
|
+
let counter = 1;
|
|
37
|
+
while (await this.workspacesRepository.findBySlug(slug)) {
|
|
38
|
+
slug = `${base}-${counter++}`;
|
|
39
|
+
}
|
|
40
|
+
return slug;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Test, TestingModule } from '@nestjs/testing';
|
|
2
|
+
import { INestApplication } from '@nestjs/common';
|
|
3
|
+
import request from 'supertest';
|
|
4
|
+
import { App } from 'supertest/types';
|
|
5
|
+
import { AppModule } from './../src/app.module';
|
|
6
|
+
|
|
7
|
+
describe('AppController (e2e)', () => {
|
|
8
|
+
let app: INestApplication<App>;
|
|
9
|
+
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
const moduleFixture: TestingModule = await Test.createTestingModule({
|
|
12
|
+
imports: [AppModule],
|
|
13
|
+
}).compile();
|
|
14
|
+
|
|
15
|
+
app = moduleFixture.createNestApplication();
|
|
16
|
+
await app.init();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('/ (GET)', () => {
|
|
20
|
+
return request(app.getHttpServer())
|
|
21
|
+
.get('/')
|
|
22
|
+
.expect(200)
|
|
23
|
+
.expect('Hello World!');
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"module": "nodenext",
|
|
4
|
+
"moduleResolution": "nodenext",
|
|
5
|
+
"resolvePackageJsonExports": true,
|
|
6
|
+
"esModuleInterop": true,
|
|
7
|
+
"isolatedModules": true,
|
|
8
|
+
"declaration": true,
|
|
9
|
+
"removeComments": true,
|
|
10
|
+
"emitDecoratorMetadata": true,
|
|
11
|
+
"experimentalDecorators": true,
|
|
12
|
+
"allowSyntheticDefaultImports": true,
|
|
13
|
+
"target": "ES2023",
|
|
14
|
+
"sourceMap": true,
|
|
15
|
+
"outDir": "./dist",
|
|
16
|
+
"baseUrl": "./",
|
|
17
|
+
"incremental": true,
|
|
18
|
+
"skipLibCheck": true,
|
|
19
|
+
"strict": true,
|
|
20
|
+
"forceConsistentCasingInFileNames": true,
|
|
21
|
+
"noImplicitAny": true,
|
|
22
|
+
"strictNullChecks": true,
|
|
23
|
+
"strictBindCallApply": true,
|
|
24
|
+
"noFallthroughCasesInSwitch": true
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
NEXT_PUBLIC_API_URL=http://localhost:3000
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema.json",
|
|
3
|
+
"style": "default",
|
|
4
|
+
"rsc": true,
|
|
5
|
+
"tsx": true,
|
|
6
|
+
"tailwind": {
|
|
7
|
+
"config": "",
|
|
8
|
+
"css": "src/app/globals.css",
|
|
9
|
+
"baseColor": "slate",
|
|
10
|
+
"cssVariables": true
|
|
11
|
+
},
|
|
12
|
+
"aliases": {
|
|
13
|
+
"components": "@/components",
|
|
14
|
+
"utils": "@/lib/utils",
|
|
15
|
+
"ui": "@/components/ui",
|
|
16
|
+
"lib": "@/lib",
|
|
17
|
+
"hooks": "@/hooks"
|
|
18
|
+
},
|
|
19
|
+
"iconLibrary": "lucide"
|
|
20
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { dirname } from 'path'
|
|
2
|
+
import { fileURLToPath } from 'url'
|
|
3
|
+
import { FlatCompat } from '@eslint/eslintrc'
|
|
4
|
+
|
|
5
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
6
|
+
const __dirname = dirname(__filename)
|
|
7
|
+
|
|
8
|
+
const compat = new FlatCompat({ baseDirectory: __dirname })
|
|
9
|
+
|
|
10
|
+
const eslintConfig = [
|
|
11
|
+
...compat.extends('next/core-web-vitals', 'next/typescript'),
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
export default eslintConfig
|