All current db functions are written. Just need to make api routes then we are done

This commit is contained in:
2024-10-16 16:51:08 -05:00
commit f734551d3a
21 changed files with 5140 additions and 0 deletions

20
src/app/layout.tsx Normal file
View File

@ -0,0 +1,20 @@
import "~/styles/globals.css";
import { GeistSans } from "geist/font/sans";
import { type Metadata } from "next";
export const metadata: Metadata = {
title: "Create T3 App",
description: "Generated by create-t3-app",
icons: [{ rel: "icon", url: "/favicon.ico" }],
};
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en" className={`${GeistSans.variable}`}>
<body>{children}</body>
</html>
);
}

37
src/app/page.tsx Normal file
View File

@ -0,0 +1,37 @@
import Link from "next/link";
export default function HomePage() {
return (
<main className="flex min-h-screen flex-col items-center justify-center bg-gradient-to-b from-[#2e026d] to-[#15162c] text-white">
<div className="container flex flex-col items-center justify-center gap-12 px-4 py-16">
<h1 className="text-5xl font-extrabold tracking-tight text-white sm:text-[5rem]">
Create <span className="text-[hsl(280,100%,70%)]">T3</span> App
</h1>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:gap-8">
<Link
className="flex max-w-xs flex-col gap-4 rounded-xl bg-white/10 p-4 text-white hover:bg-white/20"
href="https://create.t3.gg/en/usage/first-steps"
target="_blank"
>
<h3 className="text-2xl font-bold">First Steps </h3>
<div className="text-lg">
Just the basics - Everything you need to know to set up your
database and authentication.
</div>
</Link>
<Link
className="flex max-w-xs flex-col gap-4 rounded-xl bg-white/10 p-4 text-white hover:bg-white/20"
href="https://create.t3.gg/en/introduction"
target="_blank"
>
<h3 className="text-2xl font-bold">Documentation </h3>
<div className="text-lg">
Learn more about Create T3 App, the libraries it uses, and how to
deploy it.
</div>
</Link>
</div>
</div>
</main>
);
}

44
src/env.js Normal file
View File

@ -0,0 +1,44 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
export const env = createEnv({
/**
* Specify your server-side environment variables schema here. This way you can ensure the app
* isn't built with invalid env vars.
*/
server: {
DATABASE_URL: z.string().url(),
NODE_ENV: z
.enum(["development", "test", "production"])
.default("development"),
},
/**
* Specify your client-side environment variables schema here. This way you can ensure the app
* isn't built with invalid env vars. To expose them to the client, prefix them with
* `NEXT_PUBLIC_`.
*/
client: {
// NEXT_PUBLIC_CLIENTVAR: z.string(),
},
/**
* You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
* middlewares) or client-side so we need to destruct manually.
*/
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
NODE_ENV: process.env.NODE_ENV,
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
},
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
* useful for Docker builds.
*/
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
/**
* Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and
* `SOME_VAR=''` will throw an error.
*/
emptyStringAsUndefined: true,
});

18
src/server/db/index.ts Normal file
View File

@ -0,0 +1,18 @@
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import { env } from "~/env";
import * as schema from "./schema";
/**
* Cache the database connection in development. This avoids creating a new connection on every HMR
* update.
*/
const globalForDb = globalThis as unknown as {
conn: postgres.Sql | undefined;
};
const conn = globalForDb.conn ?? postgres(env.DATABASE_URL);
if (env.NODE_ENV !== "production") globalForDb.conn = conn;
export const db = drizzle(conn, { schema });

163
src/server/db/schema.ts Normal file
View File

@ -0,0 +1,163 @@
// https://orm.drizzle.team/docs/sql-schema-declaration
import { sql } from "drizzle-orm";
import {
boolean,
index,
integer,
jsonb,
numeric,
pgEnum,
pgTable,
serial,
text,
timestamp,
varchar,
} from "drizzle-orm/pg-core";
export const users = pgTable(
'users',
{
id: serial('id').primaryKey(),
appleId: varchar('apple_id', { length: 200 }).unique(),
email: varchar('email', { length: 100 }).unique().notNull(),
fullName: varchar('full_name', { length: 100 }).notNull(),
pfpUrl: varchar('pfp_url', { length: 255 }),
pushToken: varchar('push_token', { length: 100 }).unique().notNull(),
createdAt: timestamp('created_at', { withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`).notNull(),
metadata: jsonb('metadata'),
},
(table) => ({
appleIdIndex: index('apple_id_idx').on(table.appleId),
emailIndex: index('email_idx').on(table.email),
fullNameIndex: index('full_name_idx').on(table.fullName),
})
);
export const relationships = pgTable(
'relationships',
{
id: serial('id').primaryKey(),
title: varchar('title', { length: 50 })
.default("My Relationship").notNull(),
requestorId: integer('requestor_id').references(() => users.id).notNull(),
isAccepted: boolean('is_accepted').default(false),
relationshipStartDate: timestamp('relationship_start_date', { withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`).notNull(),
},
(table) => ({
requestorIdIndex: index('requestor_id_idx').on(table.requestorId),
})
);
export const userRelationships = pgTable(
'user_relationships',
{
id: serial('id').primaryKey(),
userId: integer('user_id').references(() => users.id).notNull(),
relationshipId: integer('relationship_id').references(() => relationships.id).notNull(),
},
(table) => ({
userIdIndex: index('user_id_idx').on(table.userId),
relationshipIdIndex: index('relationship_id_idx').on(table.relationshipId),
})
);
export const countdowns = pgTable(
'countdowns',
{
id: serial('id').primaryKey(),
relationshipId: integer('relationship_id').references(() => relationships.id).notNull(),
title: varchar('title', { length: 50 })
.default('Countdown to Next Visit').notNull(),
date: timestamp('date', { withTimezone: true }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`).notNull(),
},
(table) => ({
relationshipIdIndex: index('relationship_id_idx').on(table.relationshipId),
})
);
export const messages = pgTable(
'messages',
{
id: serial('id').primaryKey(),
senderId: integer('sender_id').references(() => users.id).notNull(),
receiverId: integer('receiver_id').references(() => users.id).notNull(),
text: text('text').notNull(),
createdAt: timestamp('created_at', { withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`).notNull(),
isRead: boolean('is_read').default(false),
hasLocation: boolean('has_location').default(false),
hasMedia: boolean('has_media').default(false),
hasQuickReply: boolean('has_quick_reply').default(false),
},
(table) => ({
senderIdIndex: index('sender_id_idx').on(table.senderId),
receiverIdIndex: index('receiver_id_idx').on(table.receiverId),
})
);
export const mediaTypes = pgEnum(
'message_media_types',
['image', 'video', 'audio', 'file']
);
export const media = pgTable(
'media',
{
id: serial('id').primaryKey(),
messageId: integer('message_id').references(() => messages.id).notNull(),
type: mediaTypes('type').notNull(),
url: varchar('url', { length: 255 }).notNull(),
size: numeric('size'),
metadata: varchar('metadata', { length: 255 }),
order: integer('order'),
},
(table) => ({
messageIdIndex: index('message_id_idx').on(table.messageId),
})
);
export const locations = pgTable(
'locations',
{
id: serial('id').primaryKey(),
messageId: integer('message_id').references(() => messages.id).notNull(),
latitude: numeric('latitude').notNull(),
longitude: numeric('longitude').notNull(),
},
(table) => ({
messageIdIndex: index('message_id_idx').on(table.messageId),
})
);
export const quickReplyType = pgEnum(
'quick_reply_types',
['radio', 'checkbox']
);
export const quickReplies = pgTable(
'quick_replies',
{
id: serial('id').primaryKey(),
messageId: integer('message_id').references(() => messages.id).notNull(),
type: quickReplyType('type').notNull(),
keepIt: boolean('keep_it').default(false),
},
(table) => ({
messageIdIndex: index('message_id_idx').on(table.messageId),
})
);
export const quickReplyOptions = pgTable(
'quick_reply_options',
{
id: serial('id').primaryKey(),
quickReplyId: integer('quick_reply_id').references(() => quickReplies.id).notNull(),
title: varchar('title', { length: 100 }).notNull(),
value: varchar('value', { length: 100 }).notNull(),
},
(table) => ({
quickReplyIdIndex: index('quick_reply_id_idx').on(table.quickReplyId),
})
);

306
src/server/functions.ts Normal file
View File

@ -0,0 +1,306 @@
import 'server-only';
import { db } from '~/server/db';
import * as schema from '~/server/db/schema';
import { eq, and, or, like, not } from 'drizzle-orm';
import { User,
Relationship,
UserRelationship,
RelationshipData,
Countdown,
InitialData,
Message,
MessageMedia,
MessageLocation,
QuickReply,
QuickReplyOption,
} from '~/server/types';
export const getUser = async (userId: number) => {
try {
const users = await db.select().from(schema.users)
.where(eq(schema.users.id, userId))
return (users.length > 0) ? users[0] as User : null;
} catch (error) {
console.error(error);
return null;
}
};
export const getInitialDataByAppleId = async (appleId: string) => {
try {
const users = await db.select().from(schema.users)
.where(eq(schema.users.appleId, appleId))
if (users.length === 0) return null;
const user = users[0] as User;
const userRelationships = await db.select()
.from(schema.userRelationships)
.where(eq(schema.userRelationships.userId, user.id))
let relationshipData: RelationshipData | undefined;
let countdown: Countdown | undefined;
if (userRelationships.length > 0) {
const userRelationship = userRelationships[0] as UserRelationship;
const relationships = await db.select()
.from(schema.relationships)
.where(eq(schema.relationships.id, userRelationship.relationshipId))
if (relationships.length > 0) {
const relationship = relationships[0] as Relationship;
const partners = await db.select()
.from(schema.users)
.innerJoin(schema.userRelationships,
eq(schema.users.id, schema.userRelationships.userId))
.where(
and(
eq(schema.userRelationships.relationshipId, relationship.id),
not(eq(schema.userRelationships.userId, user.id))
)
);
if (partners.length > 0) {
const partner = partners[0]?.users as User;
relationshipData = {
relationship,
partner,
};
const countdowns = await db.select()
.from(schema.countdowns)
.where(eq(schema.countdowns.relationshipId, relationship.id))
.orderBy(schema.countdowns.date)
.limit(1);
if (countdowns.length > 0) {
countdown = countdowns[0] as Countdown;
}
}
}
}
const initialData: InitialData = {
user,
relationshipData,
countdown,
};
return initialData;
} catch (error) {
console.error(error);
return null;
}
};
export const createUser = async (
appleId: string, email: string,
fullName: string, pushToken: string
) => {
try {
if (!appleId || !email || !fullName || !pushToken) {
throw new Error("Error: All required fields must be filled");
}
// Check if username or email is already taken
const existingUser = await db.select().from(schema.users)
.where(or(eq(schema.users.appleId, appleId), eq(schema.users.email, email)));
if (existingUser.length > 0) {
throw new Error("Username or email is already in use");
}
const newUsers: User[] = await db.insert(schema.users).values({
appleId, email, fullName, pushToken
}).returning() as User[]; // return the newly created user
if (!newUsers.length || !newUsers[0]?.id)
throw new Error("Failed to create new user");
return newUsers[0];
} catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to create new user: ${error.message}`);
} else {
throw new Error("Unknown error occurred while creating new user");
}
}
};
export const updatePushToken = async (userId: number, pushToken: string): Promise<boolean> => {
try {
const result = await db.update(schema.users)
.set({ pushToken: pushToken })
.where(
and(
eq(schema.users.id, userId),
not(eq(schema.users.pushToken, pushToken))
)
)
.returning({ updatedId: schema.users.id });
return result.length > 0;
} catch (error) {
console.error('Error updating push token:', error);
return false;
}
};
export const updatePfpUrl = async (userId: number, pfpUrl: string) => {
try {
const result = await db.update(schema.users)
.set({ pfpUrl: pfpUrl })
.where(eq(schema.users.id, userId))
.returning({ updatedId: schema.users.id });
return result.length > 0;
} catch (error) {
console.error('Error updating pfp url:', error);
}
};
export const checkRelationshipStatus = async (userId: number): Promise<RelationshipData> => {
try {
const user = await getUser(userId);
if (!user) throw new Error("User not found");
const userRelationship = await db.select()
.from(schema.userRelationships)
.where(eq(schema.userRelationships.userId, user.id))
.limit(1)
.then(results => results[0]);
if (!userRelationship) throw new Error('No relationships found for user');
const relationship = await db.select()
.from(schema.relationships)
.where(eq(schema.relationships.id, userRelationship.relationshipId))
.limit(1)
.then(results => results[0] as Relationship);
if (!relationship) throw new Error('Relationship not found');
const partner = await db.select()
.from(schema.users)
.innerJoin(schema.userRelationships,
eq(schema.users.id, schema.userRelationships.userId))
.where(
and(
eq(schema.userRelationships.relationshipId, relationship.id),
not(eq(schema.userRelationships.userId, user.id))
)
)
.limit(1)
.then(results => results[0]?.users as User);
if (!partner) throw new Error('No partner found for relationship');
return { relationship, partner };
} catch (error) {
console.error('Error checking relationship status:', error);
throw error; // Re-throw the error to be handled by the caller
}
};
export const updateRelationshipStatus = async (
userId: number, status: 'accepted' | 'rejected'
) => {
const users = await db.select().from(schema.users)
.where(eq(schema.users.id, userId));
const user = users[0] as User;
if (!user) throw new Error("User not found");
const userRelationships = await db.select()
.from(schema.userRelationships)
.where(eq(schema.userRelationships.userId, user.id));
if (userRelationships.length === 0) {
throw new Error('No relationships found for user');
}
const userRelationship = userRelationships[0] as UserRelationship;
const relationships = await db.select()
.from(schema.relationships)
.where(eq(schema.relationships.id, userRelationship.relationshipId));
if (relationships.length === 0) {
throw new Error('Relationship not found');
}
const relationship = relationships[0] as Relationship;
if (status === 'accepted') {
await db.update(schema.relationships)
.set({ isAccepted: true })
.where(eq(schema.relationships.id, relationship.id));
const partners = await db.select()
.from(schema.users)
.innerJoin(schema.userRelationships,
eq(schema.users.id, schema.userRelationships.userId))
.where(
and(
eq(schema.userRelationships.relationshipId, relationship.id),
not(eq(schema.userRelationships.userId, user.id))
)
);
if (partners.length === 0) {
throw new Error('No partners found for relationship');
}
const partner = partners[0]?.users as User;
const relationshipData: RelationshipData = {
relationship,
partner,
};
return relationshipData;
} else if (status === 'rejected') {
await db.delete(schema.userRelationships)
.where(eq(schema.userRelationships.id, userRelationship.id));
await db.delete(schema.relationships)
.where(eq(schema.relationships.id, relationship.id));
return null;
}
};
export const searchUsers = async (userId: number, searchTerm: string) => {
try {
const users = await db.select().from(schema.users)
.where(
and(
or(
like(schema.users.fullName, `%${searchTerm}%`),
like(schema.users.email, `%${searchTerm}%`)
),
not(eq(schema.users.id, userId))
)
);
if (users.length === 0) throw new Error("No users found");
return users as User[];
} catch (error) {
console.error('Error searching users:', error);
}
};
export const createRelationshipRequest = async (userId: number, partnerId: number) => {
try {
const user = await getUser(userId);
if (!user) throw new Error("User not found");
const partner = await getUser(partnerId);
if (!partner) throw new Error("Partner not found");
const existingRelationship = await db.select({
relationshipId: schema.userRelationships.relationshipId,
status: schema.relationships.isAccepted,
})
.from(schema.userRelationships)
.innerJoin(
schema.relationships,
eq(schema.userRelationships.relationshipId, schema.relationships.id)
)
.where(
or(
eq(schema.userRelationships.userId, user.id),
eq(schema.userRelationships.userId, partner.id)
)
).limit(1);
if (existingRelationship.length > 0) {
throw new Error("Relationship already exists");
}
const newRelationship = await db.insert(schema.relationships).values({
requestorId: user.id,
}).returning() as Relationship[];
if (!newRelationship.length || !newRelationship[0]?.id)
throw new Error("Failed to create new relationship");
const relationship = newRelationship[0];
await db.insert(schema.userRelationships).values([
{ userId: userId, relationshipId: relationship.id },
{ userId: partnerId, relationshipId: relationship.id },
]);
return { relationship, partner };
} catch (error: unknown) {
if (error instanceof Error) {
throw new Error(`Failed to create new relationship: ${error.message}`);
} else {
throw new Error("Unknown error occurred while creating new relationship");
}
}
};

150
src/server/types.ts Normal file
View File

@ -0,0 +1,150 @@
/* Types */
// User Table in DB
export type User = {
id: number;
appleId: string | null;
email: string;
fullName: string;
pfpUrl: string | null;
pushToken: string;
createdAt: Date;
metadata?: Record<string, string>;
};
// Relationship Table in DB
export type Relationship = {
id: number;
title: string;
requestorId: number;
isAccepted: boolean;
relationshipStartDate: Date;
};
export type UserRelationship = {
id: number;
userId: number;
relationshipId: number;
};
// Mutated Data from Relationship
// & UserRelationship Tables in DB
export type RelationshipData = {
relationship: Relationship;
partner: User;
};
// Countdown Table in DB
export type Countdown = {
id: number;
relationshipId: number;
title: string;
date: Date;
createdAt: Date;
};
// Mutated Data for Login
// API Response
export type InitialData = {
user: User;
relationshipData?: RelationshipData;
countdown?: Countdown;
};
// Message Table in DB
export type Message = {
id: number;
senderId: number;
receiverId: number;
text: string;
createdAt: Date;
isRead: boolean;
hasLocation: boolean;
hasMedia: boolean;
hasQuickReply: boolean;
};
// MessageMedia Table in DB
export type MessageMedia = {
id: number;
messageId: number;
mediaType:
'image' | 'video' | 'audio' | 'file';
url: string;
size?: number;
metadata?: string;
order: number;
};
// MessageLocation Table in DB
export type MessageLocation = {
id: number;
messageId: number;
latitude: number;
longitude: number;
};
// Quick Reply Table in DB
export type QuickReply = {
id: number;
messageId: number;
type: 'radio' | 'checkbox';
keepIt: boolean;
};
// Quick Reply Option Table in DB
export type QuickReplyOption = {
id: number;
quickReplyId: number;
title: string;
value: string;
};
export type GCUser = {
_id: number;
name: string;
avatar?: string;
};
export type GCQuickReplies = {
type: 'radio' | 'checkbox';
values: GCQuickReplyOptions[];
keepIt?: boolean;
};
export type GCQuickReplyOptions = {
title: string;
value: string;
};
export type GCLocation = {
latitude: number;
longitude: number;
};
export type GCMessage = {
_id: number;
text: string;
createdAt: Date;
user: GCUser;
image?: string;
video?: string;
audio?: string;
location?: GCLocation;
system?: boolean;
sent?: boolean;
received?: boolean;
pending?: boolean;
quickReplies?: GCQuickReplies;
};
export type GCState = {
messages: any[];
step: number;
loadEarlier?: boolean;
isLoadingEarlier?: boolean;
isTyping: boolean;
};
export enum ActionKind {
SEND_MESSAGE = 'SEND_MESSAGE',
LOAD_EARLIER_MESSAGES = 'LOAD_EARLIER_MESSAGES',
LOAD_EARLIER_START = 'LOAD_EARLIER_START',
SET_IS_TYPING = 'SET_IS_TYPING',
// LOAD_EARLIER_END = 'LOAD_EARLIER_END',
};
export type GCStateAction = {
type: ActionKind;
payload?: any;
};
export type NotificationMessage = {
sound?: string;
title: string;
body: string;
data?: any;
};

3
src/styles/globals.css Normal file
View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;