mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-12 16:55:37 +02:00
⭐ feat: Connect the frontend to the API (#160)
This commit is contained in:
@@ -14,11 +14,17 @@ const _schema = i.schema({
|
||||
profiles: i.entity({
|
||||
avatarUrl: i.string().optional(),
|
||||
username: i.string().indexed(),
|
||||
ownerID: i.string().unique().indexed(),
|
||||
updatedAt: i.date(),
|
||||
createdAt: i.date(),
|
||||
discriminator: i.string().indexed()
|
||||
}),
|
||||
teams: i.entity({
|
||||
name: i.string(),
|
||||
slug: i.string().unique().indexed(),
|
||||
deletedAt: i.date().optional().indexed(),
|
||||
updatedAt: i.date(),
|
||||
createdAt: i.date(),
|
||||
}),
|
||||
games: i.entity({
|
||||
name: i.string(),
|
||||
steamID: i.number().unique().indexed(),
|
||||
@@ -29,12 +35,31 @@ const _schema = i.schema({
|
||||
endedAt: i.date().optional().indexed(),
|
||||
public: i.boolean().indexed(),
|
||||
}),
|
||||
subscriptions: i.entity({
|
||||
checkoutID: i.string(),
|
||||
// quantity: i.number(),
|
||||
// frequency: i.string(),
|
||||
canceledAt: i.date(),
|
||||
// next: i.date()
|
||||
})
|
||||
},
|
||||
links: {
|
||||
UserSubscriptions: {
|
||||
forward: { on: "subscriptions", has: "one", label: "owner" },
|
||||
reverse: { on: "$users", has: "many", label: "subscriptions" }
|
||||
},
|
||||
UserProfiles: {
|
||||
forward: { on: "profiles", has: "one", label: "owner" },
|
||||
reverse: { on: "$users", has: "one", label: "profile" }
|
||||
},
|
||||
TeamsOwned: {
|
||||
forward: { on: "teams", has: "one", label: "owner" },
|
||||
reverse: { on: "$users", has: "many", label: "teamsOwned" },
|
||||
},
|
||||
TeamsJoined: {
|
||||
forward: { on: "teams", has: "many", label: "members" },
|
||||
reverse: { on: "$users", has: "many", label: "teamsJoined" },
|
||||
},
|
||||
UserMachines: {
|
||||
forward: { on: "machines", has: "one", label: "owner" },
|
||||
reverse: { on: "$users", has: "many", label: "machines" }
|
||||
|
||||
@@ -22,4 +22,24 @@ export namespace Email {
|
||||
console.log("error sending email", error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendWelcome(
|
||||
to: string,
|
||||
name: string,
|
||||
) {
|
||||
|
||||
try {
|
||||
await Client().sendTransactionalEmail(
|
||||
{
|
||||
transactionalId: "cm61jrbbx02twlstfwfcywt5u",
|
||||
email: to,
|
||||
dataVariables: {
|
||||
name
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.log("error sending email", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,25 @@ export module Examples {
|
||||
updatedAt: '2025-01-09T01:56:23.902Z'
|
||||
}
|
||||
|
||||
export const Subscription = {
|
||||
id: "0bfcb712-df13-4454-81a8-fbee66eddca4",
|
||||
checkoutID: "0bfcb712-df43-4454-81a8-fbee66eddca4",
|
||||
// productID: "0bfcb712-df43-4454-81a8-fbee66eddca4",
|
||||
// quantity: 1,
|
||||
// frequency: "monthly" as const,
|
||||
// next: '2025-01-09T01:56:23.902Z',
|
||||
canceledAt: '2025-02-09T01:56:23.902Z'
|
||||
}
|
||||
|
||||
export const Team = {
|
||||
id: "0bfcb712-df13-4454-81a8-fbee66eddca4",
|
||||
owner: true,
|
||||
name: "Jane Doe's Games",
|
||||
slug: "jane-does-games",
|
||||
createdAt: '2025-01-04T11:56:23.902Z',
|
||||
updatedAt: '2025-01-09T01:56:23.902Z'
|
||||
}
|
||||
|
||||
export const Machine = {
|
||||
id: "0bfcb712-df13-4454-81a8-fbee66eddca4",
|
||||
hostname: "DESKTOP-EUO8VSF",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Examples } from "../examples";
|
||||
import databaseClient from "../database";
|
||||
import { groupBy, map, pipe, values } from "remeda"
|
||||
import { id as createID } from "@instantdb/admin";
|
||||
import { useCurrentUser } from "../actor";
|
||||
|
||||
export module Profiles {
|
||||
const MAX_ATTEMPTS = 50;
|
||||
@@ -124,10 +125,6 @@ export module Profiles {
|
||||
export const create = fn(z.object({ username: z.string(), customDiscriminator: z.string().optional(), avatarUrl: z.string().optional(), owner: z.string() }), async (input) => {
|
||||
const username = sanitizeUsername(input.username);
|
||||
|
||||
// if (!username || username.length < 2 || username.length > 32) {
|
||||
// // throw new Error('Invalid username length');
|
||||
// }
|
||||
|
||||
const db = databaseClient()
|
||||
const id = createID()
|
||||
const now = new Date().toISOString()
|
||||
@@ -150,6 +147,7 @@ export module Profiles {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
const profiles = res.profiles
|
||||
if (profiles.length != 0) {
|
||||
@@ -176,7 +174,6 @@ export module Profiles {
|
||||
avatarUrl: input.avatarUrl,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
ownerID: input.owner,
|
||||
discriminator,
|
||||
}).link({ owner: input.owner })
|
||||
)
|
||||
@@ -214,7 +211,7 @@ export module Profiles {
|
||||
profiles: {
|
||||
$: {
|
||||
where: {
|
||||
ownerID
|
||||
owner: ownerID
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -227,6 +224,27 @@ export module Profiles {
|
||||
return null
|
||||
}
|
||||
|
||||
return profiles
|
||||
const profile = pipe(
|
||||
profiles,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
username: group[0].username,
|
||||
createdAt: group[0].createdAt,
|
||||
updatedAt: group[0].updatedAt,
|
||||
avatarUrl: group[0].avatarUrl,
|
||||
discriminator: group[0].discriminator
|
||||
}))
|
||||
)
|
||||
|
||||
return profile[0]
|
||||
}
|
||||
|
||||
export const getCurrentProfile = async () => {
|
||||
const user = useCurrentUser()
|
||||
const currentProfile = await getProfile(user.id);
|
||||
|
||||
return currentProfile
|
||||
}
|
||||
};
|
||||
205
packages/core/src/subscription/index.ts
Normal file
205
packages/core/src/subscription/index.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { z } from "zod";
|
||||
import databaseClient from "../database"
|
||||
import { fn } from "../utils";
|
||||
import { groupBy, map, pipe, values } from "remeda"
|
||||
import { Common } from "../common";
|
||||
import { Examples } from "../examples";
|
||||
import { useCurrentUser } from "../actor";
|
||||
import { id as createID } from "@instantdb/admin";
|
||||
import { Email } from "../email";
|
||||
import { Profiles } from "../profile";
|
||||
|
||||
export const SubscriptionFrequency = z.enum([
|
||||
"fixed",
|
||||
"daily",
|
||||
"weekly",
|
||||
"monthly",
|
||||
"yearly",
|
||||
]);
|
||||
|
||||
export type SubscriptionFrequency = z.infer<typeof SubscriptionFrequency>;
|
||||
|
||||
export namespace Subscriptions {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().openapi({
|
||||
description: Common.IdDescription,
|
||||
example: Examples.Subscription.id,
|
||||
}),
|
||||
checkoutID: z.string().openapi({
|
||||
description: "The polar.sh checkout id",
|
||||
example: Examples.Subscription.checkoutID,
|
||||
}),
|
||||
// productID: z.string().openapi({
|
||||
// description: "ID of the product being subscribed to.",
|
||||
// example: Examples.Subscription.productID,
|
||||
// }),
|
||||
// quantity: z.number().int().openapi({
|
||||
// description: "Quantity of the subscription.",
|
||||
// example: Examples.Subscription.quantity,
|
||||
// }),
|
||||
// frequency: SubscriptionFrequency.openapi({
|
||||
// description: "Frequency of the subscription.",
|
||||
// example: Examples.Subscription.frequency,
|
||||
// }),
|
||||
// next: z.string().or(z.number()).openapi({
|
||||
// description: "Next billing date for the subscription.",
|
||||
// example: Examples.Subscription.next,
|
||||
// }),
|
||||
canceledAt: z.string().or(z.number()).optional().openapi({
|
||||
description: "Cancelled date for the subscription.",
|
||||
example: Examples.Subscription.canceledAt,
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
ref: "Subscription",
|
||||
description: "Subscription to a Nestri product.",
|
||||
example: Examples.Subscription,
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const list = async () => {
|
||||
const db = databaseClient()
|
||||
const user = useCurrentUser()
|
||||
|
||||
const query = {
|
||||
subscriptions: {
|
||||
$: {
|
||||
where: {
|
||||
owner: user.id,
|
||||
canceledAt: { $isNull: true }
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
|
||||
const response = res.subscriptions
|
||||
if (!response || response.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const result = pipe(
|
||||
response,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
// next: group[0].next,
|
||||
// frequency: group[0].frequency as any,
|
||||
// quantity: group[0].quantity,
|
||||
// productID: group[0].productID,
|
||||
checkoutID: group[0].checkoutID,
|
||||
}))
|
||||
)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export const create = fn(Info.omit({ id: true, canceledAt: true }), async (input) => {
|
||||
// const id = createID()
|
||||
const id = createID()
|
||||
const db = databaseClient()
|
||||
const user = useCurrentUser()
|
||||
|
||||
//Use the polar.sh ID
|
||||
await db.transact(db.tx.subscriptions[id]!.update({
|
||||
// next: input.next,
|
||||
// frequency: input.frequency,
|
||||
// quantity: input.quantity,
|
||||
checkoutID: input.checkoutID,
|
||||
}).link({ owner: user.id }))
|
||||
const res = await db.auth.getUser({ id: user.id })
|
||||
const profile = await Profiles.getProfile(user.id)
|
||||
if (profile) {
|
||||
await Email.sendWelcome(res.email, profile.username)
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
export const remove = fn(z.string(), async (id) => {
|
||||
const db = databaseClient()
|
||||
|
||||
await db.transact(db.tx.subscriptions[id]!.update({
|
||||
canceledAt: new Date().toString()
|
||||
}))
|
||||
})
|
||||
|
||||
export const fromID = fn(z.string(), async (id) => {
|
||||
const db = databaseClient()
|
||||
const user = useCurrentUser()
|
||||
const query = {
|
||||
subscriptions: {
|
||||
$: {
|
||||
where: {
|
||||
id,
|
||||
//Make sure they can only get subscriptions they own
|
||||
owner: user.id,
|
||||
canceledAt: { $isNull: true }
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
|
||||
const response = res.subscriptions
|
||||
if (!response || response.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const result = pipe(
|
||||
response,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
checkoutID: group[0].checkoutID,
|
||||
// next: group[0].next,
|
||||
// frequency: group[0].frequency as any,
|
||||
// quantity: group[0].quantity,
|
||||
// productID: group[0].productID,
|
||||
}))
|
||||
)
|
||||
|
||||
return result[0]
|
||||
})
|
||||
|
||||
export const fromCheckoutID = fn(z.string(), async (id) => {
|
||||
const db = databaseClient()
|
||||
const user = useCurrentUser()
|
||||
const query = {
|
||||
subscriptions: {
|
||||
$: {
|
||||
where: {
|
||||
id,
|
||||
//Make sure they can only get subscriptions they own
|
||||
checkoutID: id,
|
||||
canceledAt: { $isNull: true }
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
|
||||
const response = res.subscriptions
|
||||
if (!response || response.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const result = pipe(
|
||||
response,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
checkoutID: group[0].checkoutID,
|
||||
}))
|
||||
)
|
||||
|
||||
return result[0]
|
||||
})
|
||||
}
|
||||
165
packages/core/src/team/index.ts
Normal file
165
packages/core/src/team/index.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { z } from "zod";
|
||||
import databaseClient from "../database"
|
||||
import { fn } from "../utils";
|
||||
import { groupBy, map, pipe, values } from "remeda"
|
||||
import { Common } from "../common";
|
||||
import { Examples } from "../examples";
|
||||
import { useCurrentUser } from "../actor";
|
||||
import { id as createID } from "@instantdb/admin";
|
||||
|
||||
export namespace Teams {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().openapi({
|
||||
description: Common.IdDescription,
|
||||
example: Examples.Team.id,
|
||||
}),
|
||||
name: z.string().openapi({
|
||||
description: "Name of the team",
|
||||
example: Examples.Team.name,
|
||||
}),
|
||||
createdAt: z.string().or(z.number()).openapi({
|
||||
description: "The time when this team was first created",
|
||||
example: Examples.Team.createdAt,
|
||||
}),
|
||||
updatedAt: z.string().or(z.number()).openapi({
|
||||
description: "The time when this team was last edited",
|
||||
example: Examples.Team.updatedAt,
|
||||
}),
|
||||
owner: z.boolean().openapi({
|
||||
description: "Whether this team is owned by this user",
|
||||
example: Examples.Team.owner,
|
||||
}),
|
||||
slug: z.string().openapi({
|
||||
description: "This is the unique name identifier for the team",
|
||||
example: Examples.Team.slug
|
||||
})
|
||||
})
|
||||
.openapi({
|
||||
ref: "Team",
|
||||
description: "A group of users sharing the same machines for gaming.",
|
||||
example: Examples.Team,
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const list = async () => {
|
||||
const db = databaseClient()
|
||||
const user = useCurrentUser()
|
||||
|
||||
const query = {
|
||||
teams: {
|
||||
$: {
|
||||
where: {
|
||||
members: user.id,
|
||||
deletedAt: { $isNull: true }
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
|
||||
const teams = res.teams
|
||||
if (!teams || teams.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const result = pipe(
|
||||
teams,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
name: group[0].name,
|
||||
createdAt: group[0].createdAt,
|
||||
updatedAt: group[0].updatedAt,
|
||||
slug: group[0].slug,
|
||||
//@ts-expect-error
|
||||
owner: group[0].owner === user.id
|
||||
}))
|
||||
)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
export const fromSlug = fn(z.string(), async (slug) => {
|
||||
const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
teams: {
|
||||
$: {
|
||||
where: {
|
||||
slug,
|
||||
deletedAt: { $isNull: true }
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
|
||||
const teams = res.teams
|
||||
if (!teams || teams.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const result = pipe(
|
||||
teams,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
name: group[0].name,
|
||||
createdAt: group[0].createdAt,
|
||||
slug: group[0].slug,
|
||||
updatedAt: group[0].updatedAt,
|
||||
//@ts-expect-error
|
||||
owner: group[0].owner === user.id
|
||||
}))
|
||||
)
|
||||
|
||||
return result[0]
|
||||
})
|
||||
|
||||
export const create = fn(Info.pick({ name: true, slug: true }), async (input) => {
|
||||
const id = createID()
|
||||
const db = databaseClient()
|
||||
const user = useCurrentUser()
|
||||
const now = new Date().toISOString()
|
||||
|
||||
await db.transact(db.tx.teams[id]!.update({
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).link({ owner: user.id, members: user.id }))
|
||||
|
||||
return id
|
||||
})
|
||||
|
||||
export const remove = fn(z.string(), async (id) => {
|
||||
const db = databaseClient()
|
||||
const now = new Date().toISOString()
|
||||
|
||||
await db.transact(db.tx.teams[id]!.update({
|
||||
deletedAt: now
|
||||
}))
|
||||
|
||||
return "ok"
|
||||
})
|
||||
|
||||
export const invite = fn(z.object({email:z.string(), id: z.string()}), async (input) => {
|
||||
//TODO:
|
||||
// const db = databaseClient()
|
||||
// const now = new Date().toISOString()
|
||||
|
||||
// await db.transact(db.tx.teams[id]!.update({
|
||||
// deletedAt: now
|
||||
// }))
|
||||
|
||||
return "ok"
|
||||
})
|
||||
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { fn } from "../utils";
|
||||
import { Common } from "../common";
|
||||
import { Examples } from "../examples";
|
||||
|
||||
export module User {
|
||||
export module Users {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().openapi({
|
||||
|
||||
Reference in New Issue
Block a user