get texts when your kid's friends are free. broadcast texts to their parents when you're free. save time automatically. schedule easier. build friendships. make memories.
playdate.help saves time and helps build friendships by coordinating tentative availability between parents for playdates. It's open-source (AGPLv3) and non-commercial.
The design document draft is here (comment and edit!)
ideal tech stack: SvelteKit, Twilio, PostgreSQL, Vercel (If too slow, DigitalOcean/Heroku/app platform).
TWILIO_AUTH_TOKENTWILIO_ACCOUNT_SIDTWILIO_PHONE_NUMBERDATABASE_PRISMA_URLDATABASE_URL_NON_POOLINGPUBLIC_URL(in prod)
We might not want to text friends and co-parents unless they've opted into to getting texts from us. What we can do, fortunately, is use an SMS hyperlink.
This does mean that inviting friends becomes a two-step process - the app creates a friend request record in the database, and then the app produces a clickable link for the inviting member to use to text their friend
Ex: <a href="sms:+18664504185?&body=Hi%2520there%252C%2520I%2527d%2520like%2520to%2520place%2520an%2520order%2520for...">Click here to text us!</a>
Since that link is shared with both parties, it can't handle authentication - it requires the user to confirm their phone number and get a 2nd text. We can pre-fill the target's phone number, though, e.g. https://playdate.help/login?phone=+18664504185
There's a separate google doc to focus on schema and implementation stuff.
We want to use UTF-8 everywhere possible.
Note that it's unclear if a separate id is needed in Prisma. While the Prisma schema definition language permits strings as primary keys, ORMs often have an optimal path designed for integer IDs that are auto-incremented. Thus, even if redundant, integer IDs are specified here and we are adding unique constraints and indexes on the string values that would otherwise serve as the primary key.
Also, all dates are stored as UTC. We convert those to the user's timezone (User.) when displaying them.
All phone numbers should be stored as max-15 digit strings in E.164 format. This format is purely numeric and does not include the assumed "+" prefix. "+" is a dial-out code, equivalent to "011" on landlines calling from the US to other countries. It's not part of the phone number itself, and the numeric equivalent to "+" varies depending on the country you're calling from. Google's libphonenumber library can be used to parse and format phone numbers. https://github.com/google/libphonenumber
Here's a parser demo: https://rawgit.com/googlei18n/libphonenumber/master/javascript/i18n/phonenumbers/demo-compiled.html
For now, we default to "US" as the country, and reject non-US numbers. We'll use US-centric formatting for display "(555) 123-7777".
In years, if this goes international, we can extract the country from the phone number with libphonenumber, and use that to determine if a caller's country is different than the friend's country, and adapt display to include country code information.
If the Postgre image isn't auto-starting (i.e., pg_start is not a recognized command), then I suggest running gp rebuild. This will point you towards a GitPod workspace whose URL looks like https://debug-lilith-playdate-kvvsqrmwmqz.ws-us89b.gitpod.io/. I've found that pg_start is recognized in this new workspace. Don't worry if pg_start says that the server is already up.
Next, you'll need to set the following in your .env in order to connect with Prisma's Postgres DB:
DATABASE_PRISMA_URL="postgresql://gitpod@localhost:5432/dev?schema=public"
This has changes from DATABASE_URL due to vercel and prisma limitations
Then to start querying the db, do npx prisma generate to gen the Prisma Client.
To reset the DB, do npx prisma migrate reset.
To push schema changes to migration.sql, do npx prisma db push.
3/8 update:
With the draft auto-generated schema, I've found that @@index([secret]) is invalid for some table definitions b/c secret isn't an actual field in those tables. Will need to dig back into the Google Doc spec to see how this should be corrected. Is it a relation field or not?
3/13 update: Got rid of errors in auto-generated schema and generated PostgreSQL schema from it (in `prisma/migrations/.../migration.sql). Have yet to go through this schema.
3/16 update: To make it work, got rid of MagicLink foreign key constraint Got a simple query to run (saving and retrieving a magicLink)
- Imported github with defaults to vercel
- Added env vars for TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER, separaete dev/preview and prod api keys
- Created a postgres "playdatebeta" and "playdate_prod" and connected it, specified "DATABASE" prefix, updated prisma schema to use direct and shadow connection strings to allow serverless deploy style, set _beta for dev and preview
- npm add @vercel/postgres and dotenv
- npm audit fix
- npm i -g vercel
- vercel env pull .env (NOTE! .env.local, .env.development.local, etc are NOT supported by dotenv it seems)
- vercel link
- deploy
vercel env pull --environment production .env yarn run prisma migrate reset
Both locally and when deployed the issues is that env vars are not populated.
Ex. npm run dev or npx yarn run dev-> Error: PRISMA_DATABASE_URL is not set
Ex. PRISMA_DATABASE_URL=1 npm run dev -> WORKS!
datasourcedb {provider="postgresql"url=env("DATABASE_URL")}generatorclient {provider="prisma-client-js"}modelHousehold {idInt@id@default(autoincrement())nameStringpublicNotesString?parentsUser[]// implicit 1-manychildrenHouseholdChild[]// implicit 1-many// We may eventually want to have explicit control over the many-to-many relationship// But there's no concrete need for that yetfriendsHousehold[]// implicit many-to-many table created behind the scenesoutboxFriendRequestsFriendRequest[]@relation(fields: [id], references: [fromHouseholdId])outboxHouseholdRequestsJoinHouseholdRequest[]@relation(fields: [id], references: [householdId])createdAtDateTime@default(now())updatedAtDateTime@updatedAt}// For inviting other households to be friends with yoursmodelFriendRequest {idInt@id@default(autoincrement())expiresDateTime?targetPhoneString// The target may not have an account yet, so we can't use a foreign keyfromUserIdIntfromUserUser@relation(fields: [fromUserId], references: [id])fromHouseholdIdIntfromHouseholdHousehold@relation(fields: [fromHouseholdId], references: [id])createdAtDateTime@default(now())@@index([secret])}// For inviting co-parents to join a householdmodelJoinHouseholdRequest {idInt@id@default(autoincrement())expiresDateTime?targetPhoneString// The target may not have an account yet, so we can't use a foreign keyhouseholdIdInthouseholdHousehold@relation(fields: [householdId], references: [id])fromUserIdIntfromUserUser@relation(fields: [fromUserId], references: [id])createdAtDateTime@default(now())@@index([secret])}modelHouseholdChild {idInt@id@default(autoincrement())householdIdInthouseholdHousehold@relation(fields: [householdId], references: [id])firstNameStringpronounSetIdIntpronounSetPronounSet@relation(fields: [pronounSetId], references: [id])lastNameString?dateOfBirthDateTime?createdAtDateTime@default(now())updatedAtDateTime@updatedAt}// For performance, we could duplicate the phone # inside the secret field, like "18129898260/secret". That way we can do a single index lookup on phone # and secret.// This streamlines testing a login validity.// We then just verify that the login link hasn't expired. If it has expired or the record has been purged, we can extract the phone number// from the url to pre-populate the phone number field on the login page.modelMagicLink{idInt@id@default(autoincrement())secretString@unique// 9 bytes of random data, base64 encoded into 12 charactersphoneString// E.164 format, max 15 digitsuserUser?@relation(fields: [phone], references: [phone])// There may not be a user yetexpiresDateTimecreatedAtDateTime@default(now())@@index([secret])}// We create a session after validating a magic linkmodelSession {idInt@id@default(autoincrement())secretString@uniquephoneString// E.164 format, max 15 digitsexpiresDateTimeuserUser@relation(fields: [userId], references: [id])userIdIntcreatedAtDateTime@default(now())}// We don't want to rely exclusively on Twilio to track STOP/START commands// We also need to know if a phone # can receive messages.// And we want to guide users to opt out of specific kinds of messages, like reminders and friend invites, rather than blocking all messages (which breaks login links!)// We also don't want a user to have to create a user account just to block friend invites.modelPhoneContactPermissions {phoneString@id// Let's see if it's very difficult to have a string as ID. It could be a regular @unique field, but then we'd have to add an index on it.blockedBooleanallowInvitesBooleanallowRemindersBooleanacceptedTermsAtDateTime?userUser?@relation(fields: [phone], references: [phone])createdAtDateTime@default(now())updatedAtDateTime@updatedAt}// Don't create a User until after a magic link has verified the phone numbermodelUser {idInt@id@default(autoincrement())// Optional, because we may not have created a household yet.householdIdInt?householdHousehold?@relation(fields: [householdId], references: [id])// What if our household was sent a friend request, but to a different parent?// Thus we have to check household.parents (each) for inboxFriendRequestsPartial and combine// We may want to optimize this one query to prevent N+1 issuesinboxFriendRequestsPartialFriendRequest[]@relation(fields: [phone], references: [targetPhone])// This very infrequently used query is for when a co-parent has invitied you to an existing householdinboxHouseholdJoinRequestsJoinHouseholdRequest[]@relation(fields: [phone], references: [targetPhone])localeString// Like en_US or es_MX - used both for formatting numbers/dates and for translationfirstNameStringlastNameString?phoneString@uniquephonePermissionsPhoneContactPermissions@relation(fields: [phone], references: [phone])timeZoneString// Time zone string, like "America/Los_Angeles"pronounSetIdIntpronounSetPronounSet@relation(fields: [pronounSetId], references: [id])// We automatically lock accounts that are reported for impersonationlockedBoolean@default(FALSE)lockedReasonString?// Email is a backup communication method in case the phone number is not usable// We make a copy of the address when it is verified, so that the verified flag doesn't stay when the user changes their email addressemailString?emailVerifiedString?reminderDatetimeDateTimereminderIntervalDaysIntacceptedTermsAtDateTimecreatedAtDateTime@default(now())updatedAtDateTime@updatedAt@@index([phone])}modelAvailabilityDate {idInt@id@default(autoincrement())householdIdInthouseholdHousehold@relation(fields: [householdId], references: [id])parentIdInt?// Eventually we may allow specifying which parent is availableparentUser?@relation(fields: [parentId], references: [id])childIdInt?// And separately, which child is available. For now we assume there's a primary child and a primary parent and let people specify variations in the notes.childHouseholdChild?@relation(fields: [childId], references: [id])dateDateTimestatusAvailabilityStatus@default(UNSPECIFIED)// We don't delete records, we just mark them as unspecifiedstartTimeDateTimeendTimeDateTimenotesString?// shared notesemoticonsString?// shared emoticonsupdatedAtDateTime@updatedAtcreatedAtDateTime@default(now())}// We could make PronounSet an enum and hardcode it, if it simplifies things.modelPronounSet {idInt@id@default(autoincrement())langCodeStringsubjectiveString// he / she / theyobjectiveString// him / her / thempossessiveString// his / her / theirpossessiveDeterminerString// his / her / theirreflexiveString// himself / herself / themselvescreatedAtDateTime@default(now())}enumAvailabilityStatus {UNSPECIFIEDBUSYAVAILABLE}// This describes the circle of friends that a household has. It's a many-to-many relationship.// Ignore for now, it's autocreatedmodelHouseholdConnection {idInt@id@default(autoincrement())householdIdInthouseholdHousehold@relation(fields: [householdId], references: [id])friendHouseholdIdIntfriendHouseholdHousehold@relation(fields: [friendHouseholdId], references: [id])createdAtDateTime@default(now())}// Ignore this model for now, it's a later featuremodelImpersonationReport {idInt@id@default(autoincrement())reportedUserIdIntreportedUserUser@relation(fields: [reportedUserId], references: [id])reporterUserIdIntreporterUserUser@relation(fields: [reporterUserId], references: [id])notesString?createdAtDateTime@default(now())updatedAtDateTime@updatedAt}