Skip to content

Commit

Permalink
refactor: improve and document users logic (denoland#554)
Browse files Browse the repository at this point in the history
Towards denoland#492
  • Loading branch information
iuioiua authored Sep 10, 2023
1 parent e66bc8e commit 9fd2b31
Show file tree
Hide file tree
Showing 4 changed files with 134 additions and 47 deletions.
22 changes: 9 additions & 13 deletions plugins/kv_oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
createUser,
deleteUserBySession,
getUser,
newUserProps,
updateUser,
type User,
} from "@/utils/db.ts";
Expand Down Expand Up @@ -73,27 +72,24 @@ export default {
);

const githubUser = await getGitHubUser(accessToken);

const user = await getUser(githubUser.login);
if (!user) {
let stripeCustomerId = undefined;
if (isStripeEnabled()) {
const customer = await stripe.customers.create({
email: githubUser.email,
});
stripeCustomerId = customer.id;
}

if (user === null) {
const user: User = {
login: githubUser.login,
stripeCustomerId,
sessionId,
...newUserProps(),
isSubscribed: false,
};
if (isStripeEnabled()) {
const customer = await stripe.customers.create();
user.stripeCustomerId = customer.id;
}
await createUser(user);
} else {
await deleteUserBySession(sessionId);
await deleteUserBySession(user.sessionId);
await updateUser({ ...user, sessionId });
}

return response;
},
},
Expand Down
4 changes: 2 additions & 2 deletions tasks/db_seed.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright 2023 the Deno authors. All rights reserved. MIT license.
// Description: Seeds the kv db with Hacker News stories
import { createItem, createUser, newUserProps } from "@/utils/db.ts";
import { createItem, createUser } from "@/utils/db.ts";
import { ulid } from "std/ulid/mod.ts";

// Reference: https://github.com/HackerNews/API
Expand Down Expand Up @@ -53,7 +53,7 @@ for (const { userLogin } of items) {
login: userLogin,
stripeCustomerId: crypto.randomUUID(),
sessionId: crypto.randomUUID(),
...newUserProps(),
isSubscribed: false,
}),
);
}
Expand Down
152 changes: 122 additions & 30 deletions utils/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -418,28 +418,25 @@ export interface User {
// AKA username
login: string;
sessionId: string;
stripeCustomerId?: string;
// The below properties can be automatically generated upon comment creation
/**
* Whether the user is subscribed to the "Premium Plan".
* @default {false}
*/
isSubscribed: boolean;
}

export function newUserProps(): Pick<User, "isSubscribed"> {
return {
isSubscribed: false,
};
stripeCustomerId?: string;
}

/**
* Creates a new user in KV. Throws if the user already exists.
* Creates a new user. Throws if the user already exists.
*
* @example
* ```ts
* import { createUser, newUserProps } from "@/utils/db.ts";
* import { createUser } from "@/utils/db.ts";
*
* await createUser({
* login: "john-doe",
* login: "john",
* sessionId: crypto.randomUUID(),
* ...newUserProps(),
* isSubscribed: false,
* });
* ```
*/
Expand All @@ -448,7 +445,12 @@ export async function createUser(user: User) {
const usersBySessionKey = ["users_by_session", user.sessionId];
const usersCountKey = ["users_count", formatDate(new Date())];

const atomicOp = kv.atomic();
const atomicOp = kv.atomic()
.check({ key: usersKey, versionstamp: null })
.check({ key: usersBySessionKey, versionstamp: null })
.set(usersKey, user)
.set(usersBySessionKey, user)
.sum(usersCountKey, 1n);

if (user.stripeCustomerId !== undefined) {
const usersByStripeCustomerKey = [
Expand All @@ -460,22 +462,31 @@ export async function createUser(user: User) {
.set(usersByStripeCustomerKey, user);
}

const res = await atomicOp
.check({ key: usersKey, versionstamp: null })
.check({ key: usersBySessionKey, versionstamp: null })
.set(usersKey, user)
.set(usersBySessionKey, user)
.sum(usersCountKey, 1n)
.commit();

if (!res.ok) throw new Error(`Failed to create user: ${user}`);
const res = await atomicOp.commit();
if (!res.ok) throw new Error("Failed to create user");
}

/**
* Creates a user, overwriting any previous data.
*
* @example
* ```ts
* import { updateUser } from "@/utils/db.ts";
*
* await updateUser({
* login: "john",
* sessionId: crypto.randomUUID(),
* isSubscribed: false,
* });
* ```
*/
export async function updateUser(user: User) {
const usersKey = ["users", user.login];
const usersBySessionKey = ["users_by_session", user.sessionId];

const atomicOp = kv.atomic();
const atomicOp = kv.atomic()
.set(usersKey, user)
.set(usersBySessionKey, user);

if (user.stripeCustomerId !== undefined) {
const usersByStripeCustomerKey = [
Expand All @@ -486,24 +497,54 @@ export async function updateUser(user: User) {
.set(usersByStripeCustomerKey, user);
}

const res = await atomicOp
.set(usersKey, user)
.set(usersBySessionKey, user)
.commit();

if (!res.ok) throw new Error(`Failed to update user: ${user}`);
const res = await atomicOp.commit();
if (!res.ok) throw new Error("Failed to update user");
}

/**
* Delete the user with the given session ID.
*
* @example
* ```ts
* import { deleteUserBySession } from "@/utils/db.ts";
*
* await deleteUserBySession("jack");
* ```
*/
export async function deleteUserBySession(sessionId: string) {
await kv.delete(["users_by_session", sessionId]);
}

/** @todo Migrate to ["users", login] key */
/**
* Gets the user with the given login.
*
* @example
* ```ts
* import { getUser } from "@/utils/db.ts";
*
* await getUser("jack"); // Returns { login: "jack", sessionId: "xxx", isSubscribed: false }
* await getUser("jill"); // Returns null
* ```
*/
export async function getUser(login: string) {
const res = await kv.get<User>(["users", login]);
return res.value;
}

/**
* Gets the user with the given session ID. The first attempt is done with
* eventual consistency. If that returns `null`, the second attempt is done
* with strong consistency. This is done for performance reasons, as this
* function is called in every route request for checking whether the session
* user is signed in.
*
* @example
* ```ts
* import { getUserBySession } from "@/utils/db.ts";
*
* await getUserBySession("xxx"); // Returns { login: "jack", sessionId: "xxx", isSubscribed: false }
* ```
*/
export async function getUserBySession(sessionId: string) {
const key = ["users_by_session", sessionId];
const eventualRes = await kv.get<User>(key, {
Expand All @@ -514,6 +555,15 @@ export async function getUserBySession(sessionId: string) {
return res.value;
}

/**
* Gets a user by their given Stripe customer ID.
*
* @example
* ```ts
* import { getUserByStripeCustomer } from "@/utils/db.ts";
*
* await getUserByStripeCustomer("123"); // Returns { login: "jack", sessionId: "xxx", isSubscribed: false, stripeCustomerId: "123" }
*/
export async function getUserByStripeCustomer(stripeCustomerId: string) {
const res = await kv.get<User>([
"users_by_stripe_customer",
Expand All @@ -522,10 +572,52 @@ export async function getUserByStripeCustomer(stripeCustomerId: string) {
return res.value;
}

/**
* Returns a {@linkcode Deno.KvListIterator} which can be used to iterate over
* the users in the database.
*
* @example
* ```
* import { listUsers } from "@/utils/db.ts";
*
* for await (const entry of listUsers()) {
* entry.value.login; // Returns "jack"
* entry.value.sessionId; // Returns "xxx"
* entry.value.isSubscribed; // Returns false
* }
* ```
*/
export function listUsers(options?: Deno.KvListOptions) {
return kv.list<User>({ prefix: ["users"] }, options);
}

/**
* Returns a boolean array indicating whether the given items have been voted
* for by the given user.
*
* @example
* ```ts
* import { getAreVotedByUser } from "@/utils/db.ts";
*
* const items = [
* {
* id: "123",
* userLogin: "jack",
* title: "Jack voted for this",
* url: "http://example.com",
* score: 1,
* },
* {
* id: "124",
* userLogin: "jill",
* title: "Jack didn't vote for this",
* url: "http://youtube.com",
* score: 0,
* }
* ];
* await getAreVotedByUser(items, "jack"); // Returns [true, false]
* ```
*/
export async function getAreVotedByUser(items: Item[], userLogin: string) {
const votedItems = await collectValues(listItemsVotedByUser(userLogin));
const votedItemsIds = votedItems.map((item) => item.id);
Expand Down
3 changes: 1 addition & 2 deletions utils/db_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ import {
listItemsByUser,
listItemsVotedByUser,
listNotifications,
newUserProps,
Notification,
updateUser,
type User,
Expand Down Expand Up @@ -65,8 +64,8 @@ export function genNewUser(): User {
return {
login: crypto.randomUUID(),
sessionId: crypto.randomUUID(),
isSubscribed: false,
stripeCustomerId: crypto.randomUUID(),
...newUserProps(),
};
}

Expand Down

0 comments on commit 9fd2b31

Please sign in to comment.