mirror of
https://github.com/wasp-lang/open-saas.git
synced 2025-06-29 10:09:45 +02:00
Merge branch 'main' into filip-refactor-user-module
This commit is contained in:
@ -1,8 +1,8 @@
|
|||||||
--- template/app/src/file-upload/operations.ts
|
--- template/app/src/file-upload/operations.ts
|
||||||
+++ opensaas-sh/app/src/file-upload/operations.ts
|
+++ opensaas-sh/app/src/file-upload/operations.ts
|
||||||
@@ -18,6 +18,18 @@
|
@@ -25,6 +25,18 @@
|
||||||
throw new HttpError(401);
|
|
||||||
}
|
const { fileType, fileName } = ensureArgsSchemaOrThrowHttpError(createFileInputSchema, rawArgs);
|
||||||
|
|
||||||
+ const numberOfFilesByUser = await context.entities.File.count({
|
+ const numberOfFilesByUser = await context.entities.File.count({
|
||||||
+ where: {
|
+ where: {
|
||||||
@ -16,6 +16,6 @@
|
|||||||
+ throw new HttpError(403, 'Thanks for trying Open SaaS. This demo only allows 2 file uploads per user.');
|
+ throw new HttpError(403, 'Thanks for trying Open SaaS. This demo only allows 2 file uploads per user.');
|
||||||
+ }
|
+ }
|
||||||
+
|
+
|
||||||
const userInfo = context.user.id;
|
const { uploadUrl, key } = await getUploadFileSignedURLFromS3({
|
||||||
|
fileType,
|
||||||
const { uploadUrl, key } = await getUploadFileSignedURLFromS3({ fileType, userInfo });
|
fileName,
|
||||||
|
@ -1,11 +0,0 @@
|
|||||||
--- template/app/src/payment/plans.ts
|
|
||||||
+++ opensaas-sh/app/src/payment/plans.ts
|
|
||||||
@@ -9,7 +9,7 @@
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaymentPlan {
|
|
||||||
- // Returns the id under which this payment plan is identified on your payment processor.
|
|
||||||
+ // Returns the id under which this payment plan is identified on your payment processor.
|
|
||||||
// E.g. this might be price id on Stripe, or variant id on LemonSqueezy.
|
|
||||||
getPaymentProcessorPlanId: () => string;
|
|
||||||
effect: PaymentPlanEffect;
|
|
@ -1,8 +1,8 @@
|
|||||||
--- template/app/src/user/operations.ts
|
--- template/app/src/user/operations.ts
|
||||||
+++ opensaas-sh/app/src/user/operations.ts
|
+++ opensaas-sh/app/src/user/operations.ts
|
||||||
@@ -38,7 +38,10 @@
|
@@ -41,7 +41,10 @@
|
||||||
subscriptionStatus?: SubscriptionStatus[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type GetPaginatedUsersOutput = {
|
type GetPaginatedUsersOutput = {
|
||||||
- users: Pick<User, 'id' | 'email' | 'username' | 'subscriptionStatus' | 'paymentProcessorUserId'>[];
|
- users: Pick<User, 'id' | 'email' | 'username' | 'subscriptionStatus' | 'paymentProcessorUserId'>[];
|
||||||
+ users: Pick<
|
+ users: Pick<
|
||||||
@ -12,28 +12,15 @@
|
|||||||
totalPages: number;
|
totalPages: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -51,8 +54,10 @@
|
@@ -85,6 +88,7 @@
|
||||||
}
|
|
||||||
|
|
||||||
const allSubscriptionStatusOptions = args.subscriptionStatus as Array<string | null> | undefined;
|
|
||||||
- const hasNotSubscribed = allSubscriptionStatusOptions?.find((status) => status === null)
|
|
||||||
- let subscriptionStatusStrings = allSubscriptionStatusOptions?.filter((status) => status !== null) as string[] | undefined
|
|
||||||
+ const hasNotSubscribed = allSubscriptionStatusOptions?.find((status) => status === null);
|
|
||||||
+ let subscriptionStatusStrings = allSubscriptionStatusOptions?.filter((status) => status !== null) as
|
|
||||||
+ | string[]
|
|
||||||
+ | undefined;
|
|
||||||
|
|
||||||
const queryResults = await context.entities.User.findMany({
|
|
||||||
skip: args.skip,
|
|
||||||
@@ -65,6 +70,7 @@
|
|
||||||
mode: 'insensitive',
|
mode: 'insensitive',
|
||||||
},
|
},
|
||||||
isAdmin: args.isAdmin,
|
isAdmin,
|
||||||
+ isMockUser: true,
|
+ isMockUser: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
OR: [
|
OR: [
|
||||||
@@ -88,7 +94,7 @@
|
@@ -108,7 +112,7 @@
|
||||||
username: true,
|
username: true,
|
||||||
isAdmin: true,
|
isAdmin: true,
|
||||||
subscriptionStatus: true,
|
subscriptionStatus: true,
|
||||||
@ -42,10 +29,10 @@
|
|||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: {
|
||||||
id: 'desc',
|
id: 'desc',
|
||||||
@@ -104,6 +110,7 @@
|
@@ -124,6 +128,7 @@
|
||||||
mode: 'insensitive',
|
mode: 'insensitive',
|
||||||
},
|
},
|
||||||
isAdmin: args.isAdmin,
|
isAdmin,
|
||||||
+ isMockUser: true,
|
+ isMockUser: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import * as z from 'zod';
|
||||||
import type { Task, GptResponse } from 'wasp/entities';
|
import type { Task, GptResponse } from 'wasp/entities';
|
||||||
import type {
|
import type {
|
||||||
GenerateGptResponse,
|
GenerateGptResponse,
|
||||||
@ -10,6 +11,7 @@ import type {
|
|||||||
import { HttpError } from 'wasp/server';
|
import { HttpError } from 'wasp/server';
|
||||||
import { GeneratedSchedule } from './schedule';
|
import { GeneratedSchedule } from './schedule';
|
||||||
import OpenAI from 'openai';
|
import OpenAI from 'openai';
|
||||||
|
import { ensureArgsSchemaOrThrowHttpError } from '../server/validation';
|
||||||
|
|
||||||
const openai = setupOpenAI();
|
const openai = setupOpenAI();
|
||||||
function setupOpenAI() {
|
function setupOpenAI() {
|
||||||
@ -20,15 +22,23 @@ function setupOpenAI() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
//#region Actions
|
//#region Actions
|
||||||
type GptPayload = {
|
|
||||||
hours: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const generateGptResponse: GenerateGptResponse<GptPayload, GeneratedSchedule> = async ({ hours }, context) => {
|
const generateGptResponseInputSchema = z.object({
|
||||||
|
hours: z.string().regex(/^\d+(\.\d+)?$/, 'Hours must be a number'),
|
||||||
|
});
|
||||||
|
|
||||||
|
type GenerateGptResponseInput = z.infer<typeof generateGptResponseInputSchema>;
|
||||||
|
|
||||||
|
export const generateGptResponse: GenerateGptResponse<GenerateGptResponseInput, GeneratedSchedule> = async (
|
||||||
|
rawArgs,
|
||||||
|
context
|
||||||
|
) => {
|
||||||
if (!context.user) {
|
if (!context.user) {
|
||||||
throw new HttpError(401);
|
throw new HttpError(401);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { hours } = ensureArgsSchemaOrThrowHttpError(generateGptResponseInputSchema, rawArgs);
|
||||||
|
|
||||||
const tasks = await context.entities.Task.findMany({
|
const tasks = await context.entities.Task.findMany({
|
||||||
where: {
|
where: {
|
||||||
user: {
|
user: {
|
||||||
@ -181,11 +191,19 @@ export const generateGptResponse: GenerateGptResponse<GptPayload, GeneratedSched
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createTask: CreateTask<Pick<Task, 'description'>, Task> = async ({ description }, context) => {
|
const createTaskInputSchema = z.object({
|
||||||
|
description: z.string().nonempty(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type CreateTaskInput = z.infer<typeof createTaskInputSchema>;
|
||||||
|
|
||||||
|
export const createTask: CreateTask<CreateTaskInput, Task> = async (rawArgs, context) => {
|
||||||
if (!context.user) {
|
if (!context.user) {
|
||||||
throw new HttpError(401);
|
throw new HttpError(401);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { description } = ensureArgsSchemaOrThrowHttpError(createTaskInputSchema, rawArgs);
|
||||||
|
|
||||||
const task = await context.entities.Task.create({
|
const task = await context.entities.Task.create({
|
||||||
data: {
|
data: {
|
||||||
description,
|
description,
|
||||||
@ -196,11 +214,21 @@ export const createTask: CreateTask<Pick<Task, 'description'>, Task> = async ({
|
|||||||
return task;
|
return task;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateTask: UpdateTask<Partial<Task>, Task> = async ({ id, isDone, time }, context) => {
|
const updateTaskInputSchema = z.object({
|
||||||
|
id: z.string().nonempty(),
|
||||||
|
isDone: z.boolean().optional(),
|
||||||
|
time: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type UpdateTaskInput = z.infer<typeof updateTaskInputSchema>;
|
||||||
|
|
||||||
|
export const updateTask: UpdateTask<UpdateTaskInput, Task> = async (rawArgs, context) => {
|
||||||
if (!context.user) {
|
if (!context.user) {
|
||||||
throw new HttpError(401);
|
throw new HttpError(401);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { id, isDone, time } = ensureArgsSchemaOrThrowHttpError(updateTaskInputSchema, rawArgs);
|
||||||
|
|
||||||
const task = await context.entities.Task.update({
|
const task = await context.entities.Task.update({
|
||||||
where: {
|
where: {
|
||||||
id,
|
id,
|
||||||
@ -214,11 +242,19 @@ export const updateTask: UpdateTask<Partial<Task>, Task> = async ({ id, isDone,
|
|||||||
return task;
|
return task;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteTask: DeleteTask<Pick<Task, 'id'>, Task> = async ({ id }, context) => {
|
const deleteTaskInputSchema = z.object({
|
||||||
|
id: z.string().nonempty(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type DeleteTaskInput = z.infer<typeof deleteTaskInputSchema>;
|
||||||
|
|
||||||
|
export const deleteTask: DeleteTask<DeleteTaskInput, Task> = async (rawArgs, context) => {
|
||||||
if (!context.user) {
|
if (!context.user) {
|
||||||
throw new HttpError(401);
|
throw new HttpError(401);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { id } = ensureArgsSchemaOrThrowHttpError(deleteTaskInputSchema, rawArgs);
|
||||||
|
|
||||||
const task = await context.entities.Task.delete({
|
const task = await context.entities.Task.delete({
|
||||||
where: {
|
where: {
|
||||||
id,
|
id,
|
||||||
|
@ -2,7 +2,13 @@ import { cn } from '../client/cn';
|
|||||||
import { useState, useEffect, FormEvent } from 'react';
|
import { useState, useEffect, FormEvent } from 'react';
|
||||||
import type { File } from 'wasp/entities';
|
import type { File } from 'wasp/entities';
|
||||||
import { useQuery, getAllFilesByUser, getDownloadFileSignedURL } from 'wasp/client/operations';
|
import { useQuery, getAllFilesByUser, getDownloadFileSignedURL } from 'wasp/client/operations';
|
||||||
import { type FileUploadError, uploadFileWithProgress, validateFile, ALLOWED_FILE_TYPES } from './fileUploading';
|
import {
|
||||||
|
type FileWithValidType,
|
||||||
|
type FileUploadError,
|
||||||
|
validateFile,
|
||||||
|
uploadFileWithProgress,
|
||||||
|
} from './fileUploading';
|
||||||
|
import { ALLOWED_FILE_TYPES } from './validation';
|
||||||
|
|
||||||
export default function FileUploadPage() {
|
export default function FileUploadPage() {
|
||||||
const [fileKeyForS3, setFileKeyForS3] = useState<File['key']>('');
|
const [fileKeyForS3, setFileKeyForS3] = useState<File['key']>('');
|
||||||
@ -64,13 +70,13 @@ export default function FileUploadPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const validationError = validateFile(file);
|
const fileValidationError = validateFile(file);
|
||||||
if (validationError) {
|
if (fileValidationError !== null) {
|
||||||
setUploadError(validationError);
|
setUploadError(fileValidationError);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await uploadFileWithProgress({ file, setUploadProgressPercent });
|
await uploadFileWithProgress({ file: file as FileWithValidType, setUploadProgressPercent });
|
||||||
formElement.reset();
|
formElement.reset();
|
||||||
allUserFiles.refetch();
|
allUserFiles.refetch();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -117,11 +123,11 @@ export default function FileUploadPage() {
|
|||||||
<>
|
<>
|
||||||
<span>Uploading {uploadProgressPercent}%</span>
|
<span>Uploading {uploadProgressPercent}%</span>
|
||||||
<div
|
<div
|
||||||
role="progressbar"
|
role='progressbar'
|
||||||
aria-valuenow={uploadProgressPercent}
|
aria-valuenow={uploadProgressPercent}
|
||||||
aria-valuemin={0}
|
aria-valuemin={0}
|
||||||
aria-valuemax={100}
|
aria-valuemax={100}
|
||||||
className="absolute bottom-0 left-0 h-1 bg-yellow-500 transition-all duration-300 ease-in-out rounded-b-md"
|
className='absolute bottom-0 left-0 h-1 bg-yellow-500 transition-all duration-300 ease-in-out rounded-b-md'
|
||||||
style={{ width: `${uploadProgressPercent}%` }}
|
style={{ width: `${uploadProgressPercent}%` }}
|
||||||
></div>
|
></div>
|
||||||
</>
|
</>
|
||||||
|
@ -1,30 +1,18 @@
|
|||||||
import { Dispatch, SetStateAction } from 'react';
|
import { Dispatch, SetStateAction } from 'react';
|
||||||
import { createFile } from 'wasp/client/operations';
|
import { createFile } from 'wasp/client/operations';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import { ALLOWED_FILE_TYPES, MAX_FILE_SIZE } from './validation';
|
||||||
|
|
||||||
|
export type FileWithValidType = Omit<File, 'type'> & { type: AllowedFileType };
|
||||||
|
type AllowedFileType = (typeof ALLOWED_FILE_TYPES)[number];
|
||||||
interface FileUploadProgress {
|
interface FileUploadProgress {
|
||||||
file: File;
|
file: FileWithValidType;
|
||||||
setUploadProgressPercent: Dispatch<SetStateAction<number>>;
|
setUploadProgressPercent: Dispatch<SetStateAction<number>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileUploadError {
|
|
||||||
message: string;
|
|
||||||
code: 'NO_FILE' | 'INVALID_FILE_TYPE' | 'FILE_TOO_LARGE' | 'UPLOAD_FAILED';
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MAX_FILE_SIZE = 5 * 1024 * 1024; // Set this to the max file size you want to allow (currently 5MB).
|
|
||||||
export const ALLOWED_FILE_TYPES = [
|
|
||||||
'image/jpeg',
|
|
||||||
'image/png',
|
|
||||||
'application/pdf',
|
|
||||||
'text/*',
|
|
||||||
'video/quicktime',
|
|
||||||
'video/mp4',
|
|
||||||
];
|
|
||||||
|
|
||||||
export async function uploadFileWithProgress({ file, setUploadProgressPercent }: FileUploadProgress) {
|
export async function uploadFileWithProgress({ file, setUploadProgressPercent }: FileUploadProgress) {
|
||||||
const { uploadUrl } = await createFile({ fileType: file.type, name: file.name });
|
const { uploadUrl } = await createFile({ fileType: file.type, fileName: file.name });
|
||||||
return await axios.put(uploadUrl, file, {
|
return axios.put(uploadUrl, file, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': file.type,
|
'Content-Type': file.type,
|
||||||
},
|
},
|
||||||
@ -37,18 +25,29 @@ export async function uploadFileWithProgress({ file, setUploadProgressPercent }:
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateFile(file: File): FileUploadError | null {
|
export interface FileUploadError {
|
||||||
|
message: string;
|
||||||
|
code: 'NO_FILE' | 'INVALID_FILE_TYPE' | 'FILE_TOO_LARGE' | 'UPLOAD_FAILED';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateFile(file: File) {
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
return {
|
return {
|
||||||
message: `File size exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.`,
|
message: `File size exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.`,
|
||||||
code: 'FILE_TOO_LARGE',
|
code: 'FILE_TOO_LARGE' as const,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (!ALLOWED_FILE_TYPES.includes(file.type)) {
|
|
||||||
|
if (!isAllowedFileType(file.type)) {
|
||||||
return {
|
return {
|
||||||
message: `File type '${file.type}' is not supported.`,
|
message: `File type '${file.type}' is not supported.`,
|
||||||
code: 'INVALID_FILE_TYPE',
|
code: 'INVALID_FILE_TYPE' as const,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAllowedFileType(fileType: string): fileType is AllowedFileType {
|
||||||
|
return (ALLOWED_FILE_TYPES as readonly string[]).includes(fileType);
|
||||||
|
}
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import * as z from 'zod';
|
||||||
import { HttpError } from 'wasp/server';
|
import { HttpError } from 'wasp/server';
|
||||||
import { type File } from 'wasp/entities';
|
import { type File } from 'wasp/entities';
|
||||||
import {
|
import {
|
||||||
@ -7,24 +8,32 @@ import {
|
|||||||
} from 'wasp/server/operations';
|
} from 'wasp/server/operations';
|
||||||
|
|
||||||
import { getUploadFileSignedURLFromS3, getDownloadFileSignedURLFromS3 } from './s3Utils';
|
import { getUploadFileSignedURLFromS3, getDownloadFileSignedURLFromS3 } from './s3Utils';
|
||||||
|
import { ensureArgsSchemaOrThrowHttpError } from '../server/validation';
|
||||||
|
import { ALLOWED_FILE_TYPES } from './validation';
|
||||||
|
|
||||||
type FileDescription = {
|
const createFileInputSchema = z.object({
|
||||||
fileType: string;
|
fileType: z.enum(ALLOWED_FILE_TYPES),
|
||||||
name: string;
|
fileName: z.string().nonempty(),
|
||||||
};
|
});
|
||||||
|
|
||||||
export const createFile: CreateFile<FileDescription, File> = async ({ fileType, name }, context) => {
|
type CreateFileInput = z.infer<typeof createFileInputSchema>;
|
||||||
|
|
||||||
|
export const createFile: CreateFile<CreateFileInput, File> = async (rawArgs, context) => {
|
||||||
if (!context.user) {
|
if (!context.user) {
|
||||||
throw new HttpError(401);
|
throw new HttpError(401);
|
||||||
}
|
}
|
||||||
|
|
||||||
const userInfo = context.user.id;
|
const { fileType, fileName } = ensureArgsSchemaOrThrowHttpError(createFileInputSchema, rawArgs);
|
||||||
|
|
||||||
const { uploadUrl, key } = await getUploadFileSignedURLFromS3({ fileType, userInfo });
|
const { uploadUrl, key } = await getUploadFileSignedURLFromS3({
|
||||||
|
fileType,
|
||||||
|
fileName,
|
||||||
|
userId: context.user.id,
|
||||||
|
});
|
||||||
|
|
||||||
return await context.entities.File.create({
|
return await context.entities.File.create({
|
||||||
data: {
|
data: {
|
||||||
name,
|
name: fileName,
|
||||||
key,
|
key,
|
||||||
uploadUrl,
|
uploadUrl,
|
||||||
type: fileType,
|
type: fileType,
|
||||||
@ -49,9 +58,14 @@ export const getAllFilesByUser: GetAllFilesByUser<void, File[]> = async (_args,
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getDownloadFileSignedURL: GetDownloadFileSignedURL<{ key: string }, string> = async (
|
const getDownloadFileSignedURLInputSchema = z.object({ key: z.string().nonempty() });
|
||||||
{ key },
|
|
||||||
_context
|
type GetDownloadFileSignedURLInput = z.infer<typeof getDownloadFileSignedURLInputSchema>;
|
||||||
) => {
|
|
||||||
|
export const getDownloadFileSignedURL: GetDownloadFileSignedURL<
|
||||||
|
GetDownloadFileSignedURLInput,
|
||||||
|
string
|
||||||
|
> = async (rawArgs, _context) => {
|
||||||
|
const { key } = ensureArgsSchemaOrThrowHttpError(getDownloadFileSignedURLInputSchema, rawArgs);
|
||||||
return await getDownloadFileSignedURLFromS3({ key });
|
return await getDownloadFileSignedURLFromS3({ key });
|
||||||
};
|
};
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import * as path from 'path';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { S3Client } from '@aws-sdk/client-s3';
|
import { S3Client } from '@aws-sdk/client-s3';
|
||||||
import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
|
import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
|
||||||
@ -13,27 +14,30 @@ const s3Client = new S3Client({
|
|||||||
|
|
||||||
type S3Upload = {
|
type S3Upload = {
|
||||||
fileType: string;
|
fileType: string;
|
||||||
userInfo: string;
|
fileName: string;
|
||||||
}
|
userId: string;
|
||||||
|
};
|
||||||
|
|
||||||
export const getUploadFileSignedURLFromS3 = async ({fileType, userInfo}: S3Upload) => {
|
export const getUploadFileSignedURLFromS3 = async ({ fileName, fileType, userId }: S3Upload) => {
|
||||||
const ex = fileType.split('/')[1];
|
const key = getS3Key(fileName, userId);
|
||||||
const Key = `${userInfo}/${randomUUID()}.${ex}`;
|
const command = new PutObjectCommand({
|
||||||
const s3Params = {
|
|
||||||
Bucket: process.env.AWS_S3_FILES_BUCKET,
|
|
||||||
Key,
|
|
||||||
ContentType: `${fileType}`,
|
|
||||||
};
|
|
||||||
const command = new PutObjectCommand(s3Params);
|
|
||||||
const uploadUrl = await getSignedUrl(s3Client, command, { expiresIn: 3600,});
|
|
||||||
return { uploadUrl, key: Key };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getDownloadFileSignedURLFromS3 = async ({ key }: { key: string }) => {
|
|
||||||
const s3Params = {
|
|
||||||
Bucket: process.env.AWS_S3_FILES_BUCKET,
|
Bucket: process.env.AWS_S3_FILES_BUCKET,
|
||||||
Key: key,
|
Key: key,
|
||||||
};
|
ContentType: fileType,
|
||||||
const command = new GetObjectCommand(s3Params);
|
});
|
||||||
|
const uploadUrl = await getSignedUrl(s3Client, command, { expiresIn: 3600 });
|
||||||
|
return { uploadUrl, key };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDownloadFileSignedURLFromS3 = async ({ key }: { key: string }) => {
|
||||||
|
const command = new GetObjectCommand({
|
||||||
|
Bucket: process.env.AWS_S3_FILES_BUCKET,
|
||||||
|
Key: key,
|
||||||
|
});
|
||||||
return await getSignedUrl(s3Client, command, { expiresIn: 3600 });
|
return await getSignedUrl(s3Client, command, { expiresIn: 3600 });
|
||||||
|
};
|
||||||
|
|
||||||
|
function getS3Key(fileName: string, userId: string) {
|
||||||
|
const ext = path.extname(fileName).slice(1);
|
||||||
|
return `${userId}/${randomUUID()}.${ext}`;
|
||||||
}
|
}
|
||||||
|
10
template/app/src/file-upload/validation.ts
Normal file
10
template/app/src/file-upload/validation.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
// Set this to the max file size you want to allow (currently 5MB).
|
||||||
|
export const MAX_FILE_SIZE = 5 * 1024 * 1024;
|
||||||
|
export const ALLOWED_FILE_TYPES = [
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
'application/pdf',
|
||||||
|
'text/*',
|
||||||
|
'video/quicktime',
|
||||||
|
'video/mp4',
|
||||||
|
] as const;
|
@ -1,20 +1,27 @@
|
|||||||
|
import * as z from 'zod';
|
||||||
import type { GenerateCheckoutSession, GetCustomerPortalUrl } from 'wasp/server/operations';
|
import type { GenerateCheckoutSession, GetCustomerPortalUrl } from 'wasp/server/operations';
|
||||||
import { PaymentPlanId, paymentPlans } from '../payment/plans';
|
import { PaymentPlanId, paymentPlans } from '../payment/plans';
|
||||||
import { paymentProcessor } from './paymentProcessor';
|
import { paymentProcessor } from './paymentProcessor';
|
||||||
import { HttpError } from 'wasp/server';
|
import { HttpError } from 'wasp/server';
|
||||||
|
import { ensureArgsSchemaOrThrowHttpError } from '../server/validation';
|
||||||
|
|
||||||
export type CheckoutSession = {
|
export type CheckoutSession = {
|
||||||
sessionUrl: string | null;
|
sessionUrl: string | null;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const generateCheckoutSession: GenerateCheckoutSession<PaymentPlanId, CheckoutSession> = async (
|
const generateCheckoutSessionSchema = z.nativeEnum(PaymentPlanId);
|
||||||
paymentPlanId,
|
|
||||||
context
|
type GenerateCheckoutSessionInput = z.infer<typeof generateCheckoutSessionSchema>;
|
||||||
) => {
|
|
||||||
|
export const generateCheckoutSession: GenerateCheckoutSession<
|
||||||
|
GenerateCheckoutSessionInput,
|
||||||
|
CheckoutSession
|
||||||
|
> = async (rawPaymentPlanId, context) => {
|
||||||
if (!context.user) {
|
if (!context.user) {
|
||||||
throw new HttpError(401);
|
throw new HttpError(401);
|
||||||
}
|
}
|
||||||
|
const paymentPlanId = ensureArgsSchemaOrThrowHttpError(generateCheckoutSessionSchema, rawPaymentPlanId);
|
||||||
const userId = context.user.id;
|
const userId = context.user.id;
|
||||||
const userEmail = context.user.email;
|
const userEmail = context.user.email;
|
||||||
if (!userEmail) {
|
if (!userEmail) {
|
||||||
@ -29,7 +36,7 @@ export const generateCheckoutSession: GenerateCheckoutSession<PaymentPlanId, Che
|
|||||||
userId,
|
userId,
|
||||||
userEmail,
|
userEmail,
|
||||||
paymentPlan,
|
paymentPlan,
|
||||||
prismaUserDelegate: context.entities.User
|
prismaUserDelegate: context.entities.User,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -1,6 +1,13 @@
|
|||||||
|
import * as z from 'zod';
|
||||||
import { requireNodeEnvVar } from '../server/utils';
|
import { requireNodeEnvVar } from '../server/utils';
|
||||||
|
|
||||||
export type SubscriptionStatus = 'past_due' | 'cancel_at_period_end' | 'active' | 'deleted';
|
export const subscriptionStatusSchema = z
|
||||||
|
.literal('past_due')
|
||||||
|
.or(z.literal('cancel_at_period_end'))
|
||||||
|
.or(z.literal('active'))
|
||||||
|
.or(z.literal('deleted'));
|
||||||
|
|
||||||
|
export type SubscriptionStatus = z.infer<typeof subscriptionStatusSchema>;
|
||||||
|
|
||||||
export enum PaymentPlanId {
|
export enum PaymentPlanId {
|
||||||
Hobby = 'hobby',
|
Hobby = 'hobby',
|
||||||
|
15
template/app/src/server/validation.ts
Normal file
15
template/app/src/server/validation.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { HttpError } from 'wasp/server';
|
||||||
|
import * as z from 'zod';
|
||||||
|
|
||||||
|
export function ensureArgsSchemaOrThrowHttpError<Schema extends z.ZodType>(
|
||||||
|
schema: Schema,
|
||||||
|
rawArgs: unknown
|
||||||
|
): z.infer<Schema> {
|
||||||
|
const parseResult = schema.safeParse(rawArgs);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
console.error(parseResult.error);
|
||||||
|
throw new HttpError(400, 'Operation arguments validation failed', { errors: parseResult.error.errors });
|
||||||
|
} else {
|
||||||
|
return parseResult.data;
|
||||||
|
}
|
||||||
|
}
|
@ -1,12 +1,23 @@
|
|||||||
|
import * as z from 'zod';
|
||||||
import { type UpdateIsUserAdminById, type GetPaginatedUsers } from 'wasp/server/operations';
|
import { type UpdateIsUserAdminById, type GetPaginatedUsers } from 'wasp/server/operations';
|
||||||
import { type User } from 'wasp/entities';
|
import { type User } from 'wasp/entities';
|
||||||
import { HttpError } from 'wasp/server';
|
import { HttpError } from 'wasp/server';
|
||||||
import { type SubscriptionStatus } from '../payment/plans';
|
import { subscriptionStatusSchema, type SubscriptionStatus } from '../payment/plans';
|
||||||
|
import { ensureArgsSchemaOrThrowHttpError } from '../server/validation';
|
||||||
|
|
||||||
export const updateIsUserAdminById: UpdateIsUserAdminById<Pick<User, 'id' | 'isAdmin'>, User> = async (
|
const updateUserAdminByIdInputSchema = z.object({
|
||||||
{ id, isAdmin },
|
id: z.string().nonempty(),
|
||||||
|
isAdmin: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type UpdateUserAdminByIdInput = z.infer<typeof updateUserAdminByIdInputSchema>;
|
||||||
|
|
||||||
|
export const updateIsUserAdminById: UpdateIsUserAdminById<UpdateUserAdminByIdInput, User> = async (
|
||||||
|
rawArgs,
|
||||||
context
|
context
|
||||||
) => {
|
) => {
|
||||||
|
const { id, isAdmin } = ensureArgsSchemaOrThrowHttpError(updateUserAdminByIdInputSchema, rawArgs);
|
||||||
|
|
||||||
if (!context.user) {
|
if (!context.user) {
|
||||||
throw new HttpError(401, 'Only authenticated users are allowed to perform this operation');
|
throw new HttpError(401, 'Only authenticated users are allowed to perform this operation');
|
||||||
}
|
}
|
||||||
@ -21,14 +32,6 @@ export const updateIsUserAdminById: UpdateIsUserAdminById<Pick<User, 'id' | 'isA
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
type GetPaginatedUsersInput = {
|
|
||||||
skip: number;
|
|
||||||
cursor?: number | undefined;
|
|
||||||
emailContains?: string;
|
|
||||||
isAdmin?: boolean;
|
|
||||||
subscriptionStatus?: SubscriptionStatus[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type GetPaginatedUsersOutput = {
|
type GetPaginatedUsersOutput = {
|
||||||
users: Pick<
|
users: Pick<
|
||||||
User,
|
User,
|
||||||
@ -37,31 +40,46 @@ type GetPaginatedUsersOutput = {
|
|||||||
totalPages: number;
|
totalPages: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getPaginatorArgsSchema = z.object({
|
||||||
|
skip: z.number(),
|
||||||
|
cursor: z.number().optional(),
|
||||||
|
emailContains: z.string().nonempty().optional(),
|
||||||
|
isAdmin: z.boolean().optional(),
|
||||||
|
subscriptionStatus: z.array(subscriptionStatusSchema).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type GetPaginatedUsersInput = z.infer<typeof getPaginatorArgsSchema>;
|
||||||
|
|
||||||
export const getPaginatedUsers: GetPaginatedUsers<GetPaginatedUsersInput, GetPaginatedUsersOutput> = async (
|
export const getPaginatedUsers: GetPaginatedUsers<GetPaginatedUsersInput, GetPaginatedUsersOutput> = async (
|
||||||
args,
|
rawArgs,
|
||||||
context
|
context
|
||||||
) => {
|
) => {
|
||||||
|
const { skip, emailContains, isAdmin, subscriptionStatus } = ensureArgsSchemaOrThrowHttpError(
|
||||||
|
getPaginatorArgsSchema,
|
||||||
|
rawArgs
|
||||||
|
);
|
||||||
|
|
||||||
if (!context.user?.isAdmin) {
|
if (!context.user?.isAdmin) {
|
||||||
throw new HttpError(401);
|
throw new HttpError(401);
|
||||||
}
|
}
|
||||||
|
|
||||||
const allSubscriptionStatusOptions = args.subscriptionStatus as Array<string | null> | undefined;
|
const allSubscriptionStatusOptions = subscriptionStatus;
|
||||||
const hasNotSubscribed = allSubscriptionStatusOptions?.find((status) => status === null);
|
const hasNotSubscribed = allSubscriptionStatusOptions?.find((status) => status === null);
|
||||||
const subscriptionStatusStrings = allSubscriptionStatusOptions?.filter((status) => status !== null) as
|
let subscriptionStatusStrings = allSubscriptionStatusOptions?.filter((status) => status !== null) as
|
||||||
| string[]
|
| string[]
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
const queryResults = await context.entities.User.findMany({
|
const queryResults = await context.entities.User.findMany({
|
||||||
skip: args.skip,
|
skip,
|
||||||
take: 10,
|
take: 10,
|
||||||
where: {
|
where: {
|
||||||
AND: [
|
AND: [
|
||||||
{
|
{
|
||||||
email: {
|
email: {
|
||||||
contains: args.emailContains || undefined,
|
contains: emailContains || undefined,
|
||||||
mode: 'insensitive',
|
mode: 'insensitive',
|
||||||
},
|
},
|
||||||
isAdmin: args.isAdmin,
|
isAdmin,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
OR: [
|
OR: [
|
||||||
@ -97,10 +115,10 @@ export const getPaginatedUsers: GetPaginatedUsers<GetPaginatedUsersInput, GetPag
|
|||||||
AND: [
|
AND: [
|
||||||
{
|
{
|
||||||
email: {
|
email: {
|
||||||
contains: args.emailContains || undefined,
|
contains: emailContains || undefined,
|
||||||
mode: 'insensitive',
|
mode: 'insensitive',
|
||||||
},
|
},
|
||||||
isAdmin: args.isAdmin,
|
isAdmin,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
OR: [
|
OR: [
|
||||||
|
Reference in New Issue
Block a user