diff --git a/packages/api/src/errors.js b/packages/api/src/errors.js index 037c76311c..b0c789ad82 100644 --- a/packages/api/src/errors.js +++ b/packages/api/src/errors.js @@ -117,6 +117,19 @@ export class MagicTokenRequiredError extends HTTPError { } MagicTokenRequiredError.CODE = 'ERROR_MAGIC_TOKEN_REQUIRED' +export class AgreementsRequiredError extends HTTPError { + /** + * @param {import("./utils/billing-types").Agreement[]} agreements + * @param {string} message + */ + constructor (agreements, message = `Missing required agreements ${agreements.join(', ')}`) { + super(message, 400) + this.name = 'AgreementRequired' + this.code = AgreementsRequiredError.CODE + } +} +AgreementsRequiredError.CODE = 'AGREEMENTS_REQUIRED' + export class InvalidCidError extends Error { /** * @param {string} cid diff --git a/packages/api/src/user.js b/packages/api/src/user.js index c82902ba8e..3ab353648a 100644 --- a/packages/api/src/user.js +++ b/packages/api/src/user.js @@ -666,7 +666,7 @@ export async function userPaymentGet (request, env) { * Save a user's payment settings. * * @param {AuthenticatedRequest} request - * @param {Pick} env + * @param {Pick} env */ export async function userPaymentPut (request, env) { const requestBody = await request.json() @@ -675,11 +675,11 @@ export async function userPaymentPut (request, env) { throw Object.assign(new Error('Invalid payment method'), { status: 400 }) } const subscriptionInput = requestBody?.subscription - if (typeof subscriptionInput !== 'object') { - throw Object.assign(new Error(`subscription must be an object, but got ${typeof subscriptionInput}`), { status: 400 }) + if (!['object', 'undefined'].includes(typeof subscriptionInput)) { + throw Object.assign(new Error(`subscription must be of type object or undefined, but got ${typeof subscriptionInput}`), { status: 400 }) } const subscriptionStorageInput = subscriptionInput?.storage - if (!(typeof subscriptionStorageInput === 'object' || subscriptionStorageInput === null)) { + if (subscriptionInput && !(typeof subscriptionStorageInput === 'object' || subscriptionStorageInput === null)) { throw Object.assign(new Error('subscription.storage must be an object or null'), { status: 400 }) } if (subscriptionStorageInput && typeof subscriptionStorageInput.price !== 'string') { @@ -691,22 +691,25 @@ export async function userPaymentPut (request, env) { status: 400 }) } - const subscriptionStorage = storagePrice - ? { price: storagePrice } - : null + /** @type {import('../src/utils/billing-types').W3PlatformSubscription|undefined} */ + const subscription = (typeof subscriptionInput === 'undefined') + ? undefined + : { + storage: storagePrice ? { price: storagePrice } : null + } const paymentMethod = { id: paymentMethodId } await savePaymentSettings( { billing: env.billing, customers: env.customers, user: { ...request.auth.user, id: request.auth.user._id }, - subscriptions: env.subscriptions + subscriptions: env.subscriptions, + agreements: env.agreements }, { paymentMethod, - subscription: { - storage: subscriptionStorage - } + subscription, + agreement: requestBody.agreement }, { name: request.auth.user.name, diff --git a/packages/api/src/utils/billing-types.ts b/packages/api/src/utils/billing-types.ts index 079947e9b9..a846485a72 100644 --- a/packages/api/src/utils/billing-types.ts +++ b/packages/api/src/utils/billing-types.ts @@ -91,11 +91,13 @@ export interface BillingEnv { billing: BillingService customers: CustomersService subscriptions: SubscriptionsService + agreements: AgreementService } export type PaymentSettings = { paymentMethod: null | PaymentMethod subscription: W3PlatformSubscription + agreement?: Agreement } export interface UserCustomerService { @@ -103,7 +105,32 @@ export interface UserCustomerService { upsertUserCustomer: (userId: string, customerId: string) => Promise } +export interface AgreementService { + createUserAgreement: (userId: string, agreement: Agreement) => Promise +} + export interface UserCreationOptions { email?: string name?: string -} \ No newline at end of file +} + +export type Agreement = 'web3.storage-tos-v1' + +/** + * Command instructing system to update the web3.storage subscription for a user + */ +export interface UpdateSubscriptionCommand { + agreement: Agreement + paymentMethod: Pick + subscription: W3PlatformSubscription +} + +/** + * Command instructing system to update the default paymentMethod for a user. + * It will not update the payment method of old subscriptions. + */ +export interface UpdateDefaultPaymentMethodCommand { + paymentMethod: Pick +} + +export type SavePaymentSettingsCommand = UpdateSubscriptionCommand | UpdateDefaultPaymentMethodCommand diff --git a/packages/api/src/utils/billing.js b/packages/api/src/utils/billing.js index 4a626f0508..1a19e4aef9 100644 --- a/packages/api/src/utils/billing.js +++ b/packages/api/src/utils/billing.js @@ -1,26 +1,109 @@ /* eslint-disable no-void */ +import { AgreementsRequiredError } from '../../src/errors.js' + /** * @typedef {import('./billing-types').StoragePriceName} StoragePriceName */ +/** @type {Record} */ +export const agreements = { + web3StorageTermsOfServiceVersion1: /** @type {const} */('web3.storage-tos-v1') +} + +/** + * Type guard whether provided value is a valid Agreement kind + * @param {any} maybeAgreement + * @returns {agreement is import('./billing-types').Agreement} + */ +export function isAgreement (maybeAgreement) { + for (const agreement of Object.values(agreements)) { + if (maybeAgreement === agreement) { + return true + } + } + return false +} + +/** + * Given a W3PlatformSubscription, return the agreements that are required to subscribe to it. + * e.g. a priced storage subscription requires signing a terms of service agreement + * @param {import('./billing-types').W3PlatformSubscription} subscription + * @returns {Set} + */ +function getRequiredAgreementsForSubscription (subscription) { + /** @type {Set} */ + const requiredAgreements = new Set([]) + if (subscription.storage !== null) { + requiredAgreements.add(agreements.web3StorageTermsOfServiceVersion1) + } + return requiredAgreements +} + +/** + * @param {import('./billing-types').W3PlatformSubscription} subscription + * @param {Set} agreements + * @returns {Set} + */ +function getMissingRequiredSubscriptionAgreements ( + subscription, + agreements +) { + /** @type {Set} */ + const missing = new Set() + for (const requiredAgreement of getRequiredAgreementsForSubscription(subscription)) { + if (!agreements.has(requiredAgreement)) { + missing.add(requiredAgreement) + } + } + return missing +} + /** * Save a user's payment settings * @param {object} ctx * @param {import('./billing-types').BillingService} ctx.billing * @param {import('./billing-types').CustomersService} ctx.customers * @param {import('./billing-types').SubscriptionsService} ctx.subscriptions + * @param {import('./billing-types').AgreementService} ctx.agreements * @param {import('./billing-types').BillingUser} ctx.user - * @param {object} paymentSettings - * @param {Pick} paymentSettings.paymentMethod - * @param {import('./billing-types').W3PlatformSubscription} paymentSettings.subscription + * @param {import('./billing-types').SavePaymentSettingsCommand} command * @param {import('./billing-types').UserCreationOptions} [userCreationOptions] */ -export async function savePaymentSettings (ctx, paymentSettings, userCreationOptions) { - const { billing, customers, user } = ctx +export async function savePaymentSettings (ctx, command, userCreationOptions) { + const { billing, customers, user, agreements } = ctx + const updatedSubscription = hasOwnProperty(command, 'subscription') ? command.subscription : undefined + const commandAgreement = hasOwnProperty(command, 'agreement') ? command.agreement : undefined + // if updating subscription, check for required agreements + if (updatedSubscription) { + const agreementsFromSettings = new Set() + if (commandAgreement) { agreementsFromSettings.add(commandAgreement) } + const missingAgreements = getMissingRequiredSubscriptionAgreements(updatedSubscription, agreementsFromSettings) + if (missingAgreements.size > 0) { + throw new AgreementsRequiredError(Array.from(missingAgreements)) + } + } const customer = await customers.getOrCreateForUser(user, userCreationOptions) - await billing.savePaymentMethod(customer.id, paymentSettings.paymentMethod.id) - await ctx.subscriptions.saveSubscription(customer.id, paymentSettings.subscription) + if (commandAgreement) { + await agreements.createUserAgreement(user.id, commandAgreement) + } + await billing.savePaymentMethod(customer.id, command.paymentMethod.id) + if (updatedSubscription) { + await ctx.subscriptions.saveSubscription(customer.id, updatedSubscription) + } +} + +/** + * Object.hasOwnProperty as typescript type guard + * @template {unknown} X + * @template {PropertyKey} Y + * @param {X} obj + * @param {Y} prop + * @returns {obj is X & Record} + * https://fettblog.eu/typescript-hasownproperty/ + */ +export function hasOwnProperty (obj, prop) { + return Object.prototype.hasOwnProperty.call(obj, prop) } /** @@ -87,6 +170,7 @@ export function createMockUserCustomerService () { const upsertUserCustomer = async (userId, customerId) => { userIdToCustomerId.set(userId, customerId) } + return { userIdToCustomerId, getUserCustomer, @@ -183,6 +267,16 @@ function createTestEnvCustomerService () { } } +/** + * Create a AgreementService for use in testing the app. + * @returns {import('./billing-types').AgreementService} + */ +export function createMockAgreementService () { + return { + async createUserAgreement (userId, agreement) {} + } +} + /** * Create BillingEnv to use when testing. * Use stubs/mocks instead of real billing service (e.g. stripe.com and/or a networked db) @@ -191,7 +285,9 @@ function createTestEnvCustomerService () { export function createMockBillingContext () { const billing = createMockBillingService() const customers = createTestEnvCustomerService() + const agreements = createMockAgreementService() return { + agreements, billing, customers, subscriptions: createMockSubscriptionsService() diff --git a/packages/api/src/utils/stripe.js b/packages/api/src/utils/stripe.js index 9a81d14f74..d7619c3a06 100644 --- a/packages/api/src/utils/stripe.js +++ b/packages/api/src/utils/stripe.js @@ -156,6 +156,10 @@ export function stripeToStripeCardPaymentMethod (paymentMethod) { * @typedef {import('./billing-types').UserCustomerService} UserCustomerService */ +/** + * @typedef {import('./billing-types').AgreementService} AgreementService + */ + /** * A CustomersService that uses stripe.com for storage */ @@ -393,7 +397,7 @@ export function createMockStripeCustomer (options = {}) { * Otherwise the mock implementations will be used. * @param {object} env * @param {string} env.STRIPE_SECRET_KEY - * @param {Pick} env.db + * @param {Pick} env.db * @returns {import('./billing-types').BillingEnv} */ export function createStripeBillingContext (env) { @@ -412,6 +416,10 @@ export function createStripeBillingContext (env) { getUserCustomer: env.db.getUserCustomer.bind(env.db) } const customers = StripeCustomersService.create(stripe, userCustomerService) + /** @type {AgreementService} */ + const agreements = { + createUserAgreement: env.db.createUserAgreement.bind(env.db) + } // attempt to get stripe price IDs from env vars let stripePrices try { @@ -427,6 +435,7 @@ export function createStripeBillingContext (env) { } const subscriptions = StripeSubscriptionsService.create(stripe, stripePrices) return { + agreements, billing, customers, subscriptions diff --git a/packages/api/test/stripe.spec.js b/packages/api/test/stripe.spec.js index 45ce3b29dc..2e5656fea3 100644 --- a/packages/api/test/stripe.spec.js +++ b/packages/api/test/stripe.spec.js @@ -1,6 +1,6 @@ /* eslint-env mocha */ import assert from 'assert' -import { createMockUserCustomerService, CustomerNotFound, randomString, storagePriceNames } from '../src/utils/billing.js' +import { createMockAgreementService, createMockUserCustomerService, CustomerNotFound, randomString, storagePriceNames } from '../src/utils/billing.js' import { createMockStripeCustomer, createMockStripeForBilling, createMockStripeForCustomersService, createMockStripeForSubscriptions, createMockStripeSubscription, createStripe, createStripeBillingContext, createStripeStorageEnvVar, createStripeStoragePricesFromEnv, stagingStripePrices, StripeBillingService, StripeCustomersService, StripeSubscriptionsService } from '../src/utils/stripe.js' /** @@ -238,7 +238,10 @@ describe('createStripeBillingContext', function () { return this.skip() } const billingContext = createStripeBillingContext({ - db: createMockUserCustomerService(), + db: { + ...createMockUserCustomerService(), + ...createMockAgreementService() + }, STRIPE_SECRET_KEY: stripeSecretKey }) const user = { id: '1', issuer: `user-${randomString()}` } diff --git a/packages/api/test/user-payment.spec.js b/packages/api/test/user-payment.spec.js index 37e5bd7245..18768e8449 100644 --- a/packages/api/test/user-payment.spec.js +++ b/packages/api/test/user-payment.spec.js @@ -3,9 +3,11 @@ import assert from 'assert' import fetch, { Request } from '@web-std/fetch' import { endpoint } from './scripts/constants.js' import { AuthorizationTestContext } from './contexts/authorization.js' -import { createMockBillingContext, createMockBillingService, createMockCustomerService, createMockPaymentMethod, createMockSubscriptionsService, randomString, savePaymentSettings, storagePriceNames } from '../src/utils/billing.js' +import { agreements, createMockAgreementService, createMockBillingContext, createMockBillingService, createMockCustomerService, createMockPaymentMethod, createMockSubscriptionsService, getPaymentSettings, randomString, savePaymentSettings, storagePriceNames } from '../src/utils/billing.js' import { userPaymentGet, userPaymentPut } from '../src/user.js' import { createMockStripeCardPaymentMethod } from '../src/utils/stripe.js' +import { getDBClient } from './scripts/helpers.js' +import { AgreementsRequiredError } from '../src/errors.js' function createBearerAuthorization (bearerToken) { return `Bearer ${bearerToken}` @@ -120,7 +122,8 @@ describe('PUT /user/payment', () => { paymentMethod: { id: desiredPaymentMethodId }, subscription: { storage: null - } + }, + agreement: 'web3.storage-tos-v1' } const res = await fetch(createSaveUserPaymentSettingsRequest({ authorization, body: JSON.stringify(desiredPaymentSettings) })) try { @@ -137,7 +140,7 @@ describe('userPaymentPut', () => { it('saves payment method using billing service', async function () { const desiredPaymentMethodId = `pm_${randomString()}` /** @type {import('src/utils/billing-types.js').PaymentSettings} */ - const paymentSettings = { paymentMethod: { id: desiredPaymentMethodId }, subscription: { storage: null } } + const paymentSettings = { paymentMethod: { id: desiredPaymentMethodId }, subscription: { storage: null }, agreement: 'web3.storage-tos-v1' } const authorization = createBearerAuthorization(AuthorizationTestContext.use(this).createUserToken()) const request = createMockAuthenticatedRequest(createSaveUserPaymentSettingsRequest({ authorization, body: JSON.stringify(paymentSettings) })) const billing = createMockBillingService() @@ -154,11 +157,46 @@ describe('userPaymentPut', () => { customers.mockCustomers.map(c => c.id).includes(billing.paymentMethodSaves[0].customerId), 'billing.paymentMethodSaves[0].customerId is in customers.mockCustomers') }) + it('throws an error if no agreement', async function () { + const desiredPaymentMethodId = `pm_${randomString()}` + const paymentSettings = { paymentMethod: { id: desiredPaymentMethodId }, subscription: { storage: null } } + const authorization = createBearerAuthorization(AuthorizationTestContext.use(this).createUserToken()) + const request = createMockAuthenticatedRequest(createSaveUserPaymentSettingsRequest({ authorization, body: JSON.stringify(paymentSettings) })) + const billing = createMockBillingService() + const customers = createMockCustomerService() + const env = { + ...createMockBillingContext(), + billing, + customers + } + assert.rejects( + async () => await userPaymentPut(request, env), + /Missing Terms of Service/ + ) + }) + it('throws an error if bad agreement', async function () { + const desiredPaymentMethodId = `pm_${randomString()}` + const paymentSettings = { paymentMethod: { id: desiredPaymentMethodId }, subscription: { storage: null }, agreement: 'bad-agreement' } + const authorization = createBearerAuthorization(AuthorizationTestContext.use(this).createUserToken()) + const request = createMockAuthenticatedRequest(createSaveUserPaymentSettingsRequest({ authorization, body: JSON.stringify(paymentSettings) })) + const billing = createMockBillingService() + const customers = createMockCustomerService() + const env = { + ...createMockBillingContext(), + billing, + customers + } + assert.rejects( + async () => await userPaymentPut(request, env), + /Invalid Terms of Service/ + ) + }) it('saves storage subscription price', async function () { /** @type {import('src/utils/billing-types.js').PaymentSettings} */ const desiredPaymentSettings = { paymentMethod: { id: `pm_${randomString()}` }, - subscription: { storage: { price: storagePriceNames.lite } } + subscription: { storage: { price: storagePriceNames.lite } }, + agreement: 'web3.storage-tos-v1' } const request = ( createMockAuthenticatedRequest( @@ -246,7 +284,7 @@ describe('/user/payment', () => { const user = createMockRequestUser() // save settings const savePaymentSettingsRequest = createMockAuthenticatedRequest( - createSaveUserPaymentSettingsRequest({ body: JSON.stringify(desiredPaymentSettings) }), + createSaveUserPaymentSettingsRequest({ body: JSON.stringify({ ...desiredPaymentSettings, agreement: 'web3.storage-tos-v1' }) }), user ) const savePaymentSettingsResponse = await userPaymentPut(savePaymentSettingsRequest, env) @@ -322,10 +360,11 @@ describe('savePaymentSettings', async function () { const billing = createMockBillingService() const paymentMethod = { id: /** @type const */ ('pm_w3-test-1') } const customers = createMockCustomerService() + const agreements = createMockAgreementService() const user = { id: '1', issuer: randomString() } const subscriptions = createMockSubscriptionsService() - const env = { billing, customers, user, subscriptions } - await savePaymentSettings(env, { paymentMethod, subscription: { storage: null } }) + const env = { billing, customers, user, subscriptions, agreements } + await savePaymentSettings(env, { paymentMethod, subscription: { storage: null }, agreement: undefined }) const { paymentMethodSaves } = billing assert.equal(paymentMethodSaves.length, 1, 'savePaymentMethod was called once') assert.deepEqual(paymentMethodSaves[0].methodId, paymentMethod.id, 'savePaymentMethod was called with method') @@ -334,7 +373,8 @@ describe('savePaymentSettings', async function () { it('saves subscription using billingService', async () => { const desiredPaymentSettings = { paymentMethod: { id: `pm_mock_${randomString()}` }, - subscription: { storage: { price: storagePriceNames.lite } } + subscription: { storage: { price: storagePriceNames.lite } }, + agreement: agreements.web3StorageTermsOfServiceVersion1 } const subscriptions = createMockSubscriptionsService() const env = { @@ -345,4 +385,139 @@ describe('savePaymentSettings', async function () { await savePaymentSettings(env, desiredPaymentSettings) assert.equal(subscriptions.saveSubscriptionCalls.length, 1, 'saveSubscription was called once') }) + it('saves record of agreement using agreementService', async () => { + const desiredPaymentSettings = { + paymentMethod: { id: `pm_mock_${randomString()}` }, + subscription: { + storage: null + }, + agreement: agreements.web3StorageTermsOfServiceVersion1 + } + const createUserAgreementCalls = [] + /** @type {import('src/utils/billing-types.js').AgreementService} */ + const stubbedAgreementsService = { + async createUserAgreement () { + createUserAgreementCalls.push(arguments) + } + } + const env = { + ...createMockBillingContext(), + agreements: stubbedAgreementsService, + subscriptions: createMockSubscriptionsService(), + user: createMockRequestUser() + } + await savePaymentSettings(env, desiredPaymentSettings) + assert.equal(createUserAgreementCalls.length, 1, 'createUserAgreement was called once') + assert.equal(createUserAgreementCalls[0][0], env.user.id) + assert.equal(createUserAgreementCalls[0][1], desiredPaymentSettings.agreement) + }) + it('will not save storage subscription without tos agreement', async () => { + const desiredPaymentSettings = { + paymentMethod: { id: `pm_mock_${randomString()}` }, + subscription: { storage: { price: storagePriceNames.lite } }, + agreement: undefined + } + const subscriptions = createMockSubscriptionsService() + const db = getDBClient() + const env = { + ...createMockBillingContext(), + agreements: db, + subscriptions, + user: createMockRequestUser() + } + let saved = false + try { + await savePaymentSettings(env, desiredPaymentSettings) + saved = true + } catch (error) { + assert.ok(error instanceof AgreementsRequiredError, 'error is AgreementRequiredError') + } + assert.notEqual(saved, true, 'should not have been able to savePaymentSettings without tos agreement') + assert.equal(subscriptions.saveSubscriptionCalls.length, 0, 'saveSubscription was not called') + }) + it('can save with subscription and tos agreement several times', async () => { + const desiredPaymentSettings = { + paymentMethod: { id: `pm_mock_${randomString()}` }, + agreement: agreements.web3StorageTermsOfServiceVersion1 + } + const db = getDBClient() + const user = await createDbUser(db) + const env = { + ...createMockBillingContext(), + // it's important to use the real db agreement table here + // because previously a uniqueness constraint + // here would prevent this test from passing + agreements: db, + subscriptions: createMockSubscriptionsService(), + user + } + const storagePriceProgression = [ + storagePriceNames.free, + storagePriceNames.lite, + storagePriceNames.pro + ] + for (const storagePrice of storagePriceProgression) { + await savePaymentSettings(env, { + ...desiredPaymentSettings, + subscription: { storage: { price: storagePrice } } + }) + } + }) + it('can save paymentMethod alone without any agreements', async () => { + const desiredPaymentSettings = { + paymentMethod: { id: `pm_mock_${randomString()}` }, + subscription: { + storage: null + }, + agreement: undefined + } + const db = getDBClient() + const user = await createDbUser(db) + const env = { + ...createMockBillingContext(), + agreements: db, + subscriptions: createMockSubscriptionsService(), + user + } + await savePaymentSettings(env, desiredPaymentSettings) + }) + it('can save storage subscription, then update only the default payment method', async () => { + const initialPaymentSettings = { + paymentMethod: { id: `pm_mock_${randomString()}` }, + subscription: { + storage: { price: storagePriceNames.lite } + }, + agreement: agreements.web3StorageTermsOfServiceVersion1 + } + const paymentMethodUpdate = { + paymentMethod: { id: `pm_mock_${randomString()}` } + } + const db = getDBClient() + const user = await createDbUser(db) + const env = { + ...createMockBillingContext(), + agreements: db, + subscriptions: createMockSubscriptionsService(), + user + } + await savePaymentSettings(env, initialPaymentSettings) + await savePaymentSettings(env, paymentMethodUpdate) + const paymentSettingsAfterUpdate = await getPaymentSettings(env) + assert.ok(!(paymentSettingsAfterUpdate instanceof Error), 'should not have gotten an error') + assert.equal(paymentSettingsAfterUpdate.paymentMethod?.id, paymentMethodUpdate.paymentMethod.id) + assert.equal(paymentSettingsAfterUpdate.subscription.storage?.price, initialPaymentSettings.subscription.storage.price) + }) }) + +/** + * Create a user in the db with random info + * @param {import('@web3-storage/db').DBClient} db + */ +async function createDbUser (db) { + return await db.upsertUser({ + issuer: randomString(), + name: randomString(), + email: `${randomString()}@example.com`, + publicAddress: randomString() + }) +} diff --git a/packages/db/db-client-types.ts b/packages/db/db-client-types.ts index 20543d21f7..7201be5e0c 100644 --- a/packages/db/db-client-types.ts +++ b/packages/db/db-client-types.ts @@ -353,3 +353,5 @@ export type LogEmailSentInput = { emailType: string, messageId: string } + +export type AgreementKind = 'web3.storage-tos-v1' diff --git a/packages/db/index.d.ts b/packages/db/index.d.ts index c4d15804e5..591e456075 100644 --- a/packages/db/index.d.ts +++ b/packages/db/index.d.ts @@ -31,6 +31,7 @@ import type { EmailSentInput, LogEmailSentInput, GetUserOptions, + AgreementKind, } from './db-client-types' export class DBClient { @@ -70,6 +71,7 @@ export class DBClient { createUserTag(userId: string, tag: UserTagInput): Promise getUserTags(userId: string): Promise upsertUserCustomer(userId: string, customerId: string): Promise + createUserAgreement(userId: string, agreement: AgreementKind): Promise getUserCustomer(userId: string): Promise<{ id: string }|null> } diff --git a/packages/db/index.js b/packages/db/index.js index b07f31c615..b4b118759f 100644 --- a/packages/db/index.js +++ b/packages/db/index.js @@ -1295,6 +1295,25 @@ export class DBClient { } } + /** + * @param {string} userId + * @param {import('./db-client-types').AgreementKind} agreement + * @returns {Promise} + */ + async createUserAgreement (userId, agreement) { + const { error } = await this._client + .from('agreement') + .insert({ + user_id: userId, + agreement + }) + .single() + + if (error) { + throw new DBError(error) + } + } + /** * Get the Customer for a user * @param {string} userId diff --git a/packages/db/postgres/migrations/029-remove-terms-of-service-add-agreement.sql b/packages/db/postgres/migrations/029-remove-terms-of-service-add-agreement.sql new file mode 100644 index 0000000000..7174da1a37 --- /dev/null +++ b/packages/db/postgres/migrations/029-remove-terms-of-service-add-agreement.sql @@ -0,0 +1,9 @@ +-- no longer unique +ALTER TABLE terms_of_service DROP CONSTRAINT terms_of_service_user_id_agreement_key; + +-- rename table terms_of_service -> agreement +ALTER TABLE terms_of_service RENAME TO agreement; +-- and things named after old table name +ALTER INDEX terms_of_service_user_id_idx RENAME TO agreement_user_id_idx; +ALTER TABLE agreement RENAME CONSTRAINT terms_of_service_pkey TO agreement_pkey; +ALTER TABLE agreement RENAME CONSTRAINT terms_of_service_user_id_fkey TO agreement_user_id_fkey; diff --git a/packages/db/postgres/pg-rest-api-types.d.ts b/packages/db/postgres/pg-rest-api-types.d.ts index c8fab71a70..ec99da6693 100644 --- a/packages/db/postgres/pg-rest-api-types.d.ts +++ b/packages/db/postgres/pg-rest-api-types.d.ts @@ -53,6 +53,102 @@ export interface paths { }; }; }; + "/agreement": { + get: { + parameters: { + query: { + id?: parameters["rowFilter.agreement.id"]; + user_id?: parameters["rowFilter.agreement.user_id"]; + agreement?: parameters["rowFilter.agreement.agreement"]; + inserted_at?: parameters["rowFilter.agreement.inserted_at"]; + /** Filtering Columns */ + select?: parameters["select"]; + /** Ordering */ + order?: parameters["order"]; + /** Limiting and Pagination */ + offset?: parameters["offset"]; + /** Limiting and Pagination */ + limit?: parameters["limit"]; + }; + header: { + /** Limiting and Pagination */ + Range?: parameters["range"]; + /** Limiting and Pagination */ + "Range-Unit"?: parameters["rangeUnit"]; + /** Preference */ + Prefer?: parameters["preferCount"]; + }; + }; + responses: { + /** OK */ + 200: { + schema: definitions["agreement"][]; + }; + /** Partial Content */ + 206: unknown; + }; + }; + post: { + parameters: { + body: { + /** agreement */ + agreement?: definitions["agreement"]; + }; + query: { + /** Filtering Columns */ + select?: parameters["select"]; + }; + header: { + /** Preference */ + Prefer?: parameters["preferReturn"]; + }; + }; + responses: { + /** Created */ + 201: unknown; + }; + }; + delete: { + parameters: { + query: { + id?: parameters["rowFilter.agreement.id"]; + user_id?: parameters["rowFilter.agreement.user_id"]; + agreement?: parameters["rowFilter.agreement.agreement"]; + inserted_at?: parameters["rowFilter.agreement.inserted_at"]; + }; + header: { + /** Preference */ + Prefer?: parameters["preferReturn"]; + }; + }; + responses: { + /** No Content */ + 204: never; + }; + }; + patch: { + parameters: { + query: { + id?: parameters["rowFilter.agreement.id"]; + user_id?: parameters["rowFilter.agreement.user_id"]; + agreement?: parameters["rowFilter.agreement.agreement"]; + inserted_at?: parameters["rowFilter.agreement.inserted_at"]; + }; + body: { + /** agreement */ + agreement?: definitions["agreement"]; + }; + header: { + /** Preference */ + Prefer?: parameters["preferReturn"]; + }; + }; + responses: { + /** No Content */ + 204: never; + }; + }; + }; "/auth_key": { get: { parameters: { @@ -551,111 +647,6 @@ export interface paths { }; }; }; - "/name": { - get: { - parameters: { - query: { - key?: parameters["rowFilter.name.key"]; - record?: parameters["rowFilter.name.record"]; - has_v2_sig?: parameters["rowFilter.name.has_v2_sig"]; - seqno?: parameters["rowFilter.name.seqno"]; - validity?: parameters["rowFilter.name.validity"]; - inserted_at?: parameters["rowFilter.name.inserted_at"]; - updated_at?: parameters["rowFilter.name.updated_at"]; - /** Filtering Columns */ - select?: parameters["select"]; - /** Ordering */ - order?: parameters["order"]; - /** Limiting and Pagination */ - offset?: parameters["offset"]; - /** Limiting and Pagination */ - limit?: parameters["limit"]; - }; - header: { - /** Limiting and Pagination */ - Range?: parameters["range"]; - /** Limiting and Pagination */ - "Range-Unit"?: parameters["rangeUnit"]; - /** Preference */ - Prefer?: parameters["preferCount"]; - }; - }; - responses: { - /** OK */ - 200: { - schema: definitions["name"][]; - }; - /** Partial Content */ - 206: unknown; - }; - }; - post: { - parameters: { - body: { - /** name */ - name?: definitions["name"]; - }; - query: { - /** Filtering Columns */ - select?: parameters["select"]; - }; - header: { - /** Preference */ - Prefer?: parameters["preferReturn"]; - }; - }; - responses: { - /** Created */ - 201: unknown; - }; - }; - delete: { - parameters: { - query: { - key?: parameters["rowFilter.name.key"]; - record?: parameters["rowFilter.name.record"]; - has_v2_sig?: parameters["rowFilter.name.has_v2_sig"]; - seqno?: parameters["rowFilter.name.seqno"]; - validity?: parameters["rowFilter.name.validity"]; - inserted_at?: parameters["rowFilter.name.inserted_at"]; - updated_at?: parameters["rowFilter.name.updated_at"]; - }; - header: { - /** Preference */ - Prefer?: parameters["preferReturn"]; - }; - }; - responses: { - /** No Content */ - 204: never; - }; - }; - patch: { - parameters: { - query: { - key?: parameters["rowFilter.name.key"]; - record?: parameters["rowFilter.name.record"]; - has_v2_sig?: parameters["rowFilter.name.has_v2_sig"]; - seqno?: parameters["rowFilter.name.seqno"]; - validity?: parameters["rowFilter.name.validity"]; - inserted_at?: parameters["rowFilter.name.inserted_at"]; - updated_at?: parameters["rowFilter.name.updated_at"]; - }; - body: { - /** name */ - name?: definitions["name"]; - }; - header: { - /** Preference */ - Prefer?: parameters["preferReturn"]; - }; - }; - responses: { - /** No Content */ - 204: never; - }; - }; - }; "/pin": { get: { parameters: { @@ -1064,14 +1055,21 @@ export interface paths { }; }; }; - "/terms_of_service": { + "/upload": { get: { parameters: { query: { - id?: parameters["rowFilter.terms_of_service.id"]; - user_id?: parameters["rowFilter.terms_of_service.user_id"]; - agreement?: parameters["rowFilter.terms_of_service.agreement"]; - inserted_at?: parameters["rowFilter.terms_of_service.inserted_at"]; + id?: parameters["rowFilter.upload.id"]; + user_id?: parameters["rowFilter.upload.user_id"]; + auth_key_id?: parameters["rowFilter.upload.auth_key_id"]; + content_cid?: parameters["rowFilter.upload.content_cid"]; + source_cid?: parameters["rowFilter.upload.source_cid"]; + type?: parameters["rowFilter.upload.type"]; + name?: parameters["rowFilter.upload.name"]; + backup_urls?: parameters["rowFilter.upload.backup_urls"]; + inserted_at?: parameters["rowFilter.upload.inserted_at"]; + updated_at?: parameters["rowFilter.upload.updated_at"]; + deleted_at?: parameters["rowFilter.upload.deleted_at"]; /** Filtering Columns */ select?: parameters["select"]; /** Ordering */ @@ -1093,7 +1091,7 @@ export interface paths { responses: { /** OK */ 200: { - schema: definitions["terms_of_service"][]; + schema: definitions["upload"][]; }; /** Partial Content */ 206: unknown; @@ -1102,8 +1100,8 @@ export interface paths { post: { parameters: { body: { - /** terms_of_service */ - terms_of_service?: definitions["terms_of_service"]; + /** upload */ + upload?: definitions["upload"]; }; query: { /** Filtering Columns */ @@ -1122,10 +1120,17 @@ export interface paths { delete: { parameters: { query: { - id?: parameters["rowFilter.terms_of_service.id"]; - user_id?: parameters["rowFilter.terms_of_service.user_id"]; - agreement?: parameters["rowFilter.terms_of_service.agreement"]; - inserted_at?: parameters["rowFilter.terms_of_service.inserted_at"]; + id?: parameters["rowFilter.upload.id"]; + user_id?: parameters["rowFilter.upload.user_id"]; + auth_key_id?: parameters["rowFilter.upload.auth_key_id"]; + content_cid?: parameters["rowFilter.upload.content_cid"]; + source_cid?: parameters["rowFilter.upload.source_cid"]; + type?: parameters["rowFilter.upload.type"]; + name?: parameters["rowFilter.upload.name"]; + backup_urls?: parameters["rowFilter.upload.backup_urls"]; + inserted_at?: parameters["rowFilter.upload.inserted_at"]; + updated_at?: parameters["rowFilter.upload.updated_at"]; + deleted_at?: parameters["rowFilter.upload.deleted_at"]; }; header: { /** Preference */ @@ -1140,14 +1145,21 @@ export interface paths { patch: { parameters: { query: { - id?: parameters["rowFilter.terms_of_service.id"]; - user_id?: parameters["rowFilter.terms_of_service.user_id"]; - agreement?: parameters["rowFilter.terms_of_service.agreement"]; - inserted_at?: parameters["rowFilter.terms_of_service.inserted_at"]; + id?: parameters["rowFilter.upload.id"]; + user_id?: parameters["rowFilter.upload.user_id"]; + auth_key_id?: parameters["rowFilter.upload.auth_key_id"]; + content_cid?: parameters["rowFilter.upload.content_cid"]; + source_cid?: parameters["rowFilter.upload.source_cid"]; + type?: parameters["rowFilter.upload.type"]; + name?: parameters["rowFilter.upload.name"]; + backup_urls?: parameters["rowFilter.upload.backup_urls"]; + inserted_at?: parameters["rowFilter.upload.inserted_at"]; + updated_at?: parameters["rowFilter.upload.updated_at"]; + deleted_at?: parameters["rowFilter.upload.deleted_at"]; }; body: { - /** terms_of_service */ - terms_of_service?: definitions["terms_of_service"]; + /** upload */ + upload?: definitions["upload"]; }; header: { /** Preference */ @@ -1160,21 +1172,19 @@ export interface paths { }; }; }; - "/upload": { + "/user": { get: { parameters: { query: { - id?: parameters["rowFilter.upload.id"]; - user_id?: parameters["rowFilter.upload.user_id"]; - auth_key_id?: parameters["rowFilter.upload.auth_key_id"]; - content_cid?: parameters["rowFilter.upload.content_cid"]; - source_cid?: parameters["rowFilter.upload.source_cid"]; - type?: parameters["rowFilter.upload.type"]; - name?: parameters["rowFilter.upload.name"]; - backup_urls?: parameters["rowFilter.upload.backup_urls"]; - inserted_at?: parameters["rowFilter.upload.inserted_at"]; - updated_at?: parameters["rowFilter.upload.updated_at"]; - deleted_at?: parameters["rowFilter.upload.deleted_at"]; + id?: parameters["rowFilter.user.id"]; + name?: parameters["rowFilter.user.name"]; + picture?: parameters["rowFilter.user.picture"]; + email?: parameters["rowFilter.user.email"]; + issuer?: parameters["rowFilter.user.issuer"]; + github?: parameters["rowFilter.user.github"]; + public_address?: parameters["rowFilter.user.public_address"]; + inserted_at?: parameters["rowFilter.user.inserted_at"]; + updated_at?: parameters["rowFilter.user.updated_at"]; /** Filtering Columns */ select?: parameters["select"]; /** Ordering */ @@ -1196,7 +1206,7 @@ export interface paths { responses: { /** OK */ 200: { - schema: definitions["upload"][]; + schema: definitions["user"][]; }; /** Partial Content */ 206: unknown; @@ -1205,8 +1215,8 @@ export interface paths { post: { parameters: { body: { - /** upload */ - upload?: definitions["upload"]; + /** user */ + user?: definitions["user"]; }; query: { /** Filtering Columns */ @@ -1225,17 +1235,15 @@ export interface paths { delete: { parameters: { query: { - id?: parameters["rowFilter.upload.id"]; - user_id?: parameters["rowFilter.upload.user_id"]; - auth_key_id?: parameters["rowFilter.upload.auth_key_id"]; - content_cid?: parameters["rowFilter.upload.content_cid"]; - source_cid?: parameters["rowFilter.upload.source_cid"]; - type?: parameters["rowFilter.upload.type"]; - name?: parameters["rowFilter.upload.name"]; - backup_urls?: parameters["rowFilter.upload.backup_urls"]; - inserted_at?: parameters["rowFilter.upload.inserted_at"]; - updated_at?: parameters["rowFilter.upload.updated_at"]; - deleted_at?: parameters["rowFilter.upload.deleted_at"]; + id?: parameters["rowFilter.user.id"]; + name?: parameters["rowFilter.user.name"]; + picture?: parameters["rowFilter.user.picture"]; + email?: parameters["rowFilter.user.email"]; + issuer?: parameters["rowFilter.user.issuer"]; + github?: parameters["rowFilter.user.github"]; + public_address?: parameters["rowFilter.user.public_address"]; + inserted_at?: parameters["rowFilter.user.inserted_at"]; + updated_at?: parameters["rowFilter.user.updated_at"]; }; header: { /** Preference */ @@ -1250,21 +1258,19 @@ export interface paths { patch: { parameters: { query: { - id?: parameters["rowFilter.upload.id"]; - user_id?: parameters["rowFilter.upload.user_id"]; - auth_key_id?: parameters["rowFilter.upload.auth_key_id"]; - content_cid?: parameters["rowFilter.upload.content_cid"]; - source_cid?: parameters["rowFilter.upload.source_cid"]; - type?: parameters["rowFilter.upload.type"]; - name?: parameters["rowFilter.upload.name"]; - backup_urls?: parameters["rowFilter.upload.backup_urls"]; - inserted_at?: parameters["rowFilter.upload.inserted_at"]; - updated_at?: parameters["rowFilter.upload.updated_at"]; - deleted_at?: parameters["rowFilter.upload.deleted_at"]; + id?: parameters["rowFilter.user.id"]; + name?: parameters["rowFilter.user.name"]; + picture?: parameters["rowFilter.user.picture"]; + email?: parameters["rowFilter.user.email"]; + issuer?: parameters["rowFilter.user.issuer"]; + github?: parameters["rowFilter.user.github"]; + public_address?: parameters["rowFilter.user.public_address"]; + inserted_at?: parameters["rowFilter.user.inserted_at"]; + updated_at?: parameters["rowFilter.user.updated_at"]; }; body: { - /** upload */ - upload?: definitions["upload"]; + /** user */ + user?: definitions["user"]; }; header: { /** Preference */ @@ -1277,19 +1283,13 @@ export interface paths { }; }; }; - "/user": { + "/user_customer": { get: { parameters: { query: { - id?: parameters["rowFilter.user.id"]; - name?: parameters["rowFilter.user.name"]; - picture?: parameters["rowFilter.user.picture"]; - email?: parameters["rowFilter.user.email"]; - issuer?: parameters["rowFilter.user.issuer"]; - github?: parameters["rowFilter.user.github"]; - public_address?: parameters["rowFilter.user.public_address"]; - inserted_at?: parameters["rowFilter.user.inserted_at"]; - updated_at?: parameters["rowFilter.user.updated_at"]; + id?: parameters["rowFilter.user_customer.id"]; + user_id?: parameters["rowFilter.user_customer.user_id"]; + customer_id?: parameters["rowFilter.user_customer.customer_id"]; /** Filtering Columns */ select?: parameters["select"]; /** Ordering */ @@ -1311,7 +1311,7 @@ export interface paths { responses: { /** OK */ 200: { - schema: definitions["user"][]; + schema: definitions["user_customer"][]; }; /** Partial Content */ 206: unknown; @@ -1320,8 +1320,8 @@ export interface paths { post: { parameters: { body: { - /** user */ - user?: definitions["user"]; + /** user_customer */ + user_customer?: definitions["user_customer"]; }; query: { /** Filtering Columns */ @@ -1340,15 +1340,9 @@ export interface paths { delete: { parameters: { query: { - id?: parameters["rowFilter.user.id"]; - name?: parameters["rowFilter.user.name"]; - picture?: parameters["rowFilter.user.picture"]; - email?: parameters["rowFilter.user.email"]; - issuer?: parameters["rowFilter.user.issuer"]; - github?: parameters["rowFilter.user.github"]; - public_address?: parameters["rowFilter.user.public_address"]; - inserted_at?: parameters["rowFilter.user.inserted_at"]; - updated_at?: parameters["rowFilter.user.updated_at"]; + id?: parameters["rowFilter.user_customer.id"]; + user_id?: parameters["rowFilter.user_customer.user_id"]; + customer_id?: parameters["rowFilter.user_customer.customer_id"]; }; header: { /** Preference */ @@ -1363,19 +1357,13 @@ export interface paths { patch: { parameters: { query: { - id?: parameters["rowFilter.user.id"]; - name?: parameters["rowFilter.user.name"]; - picture?: parameters["rowFilter.user.picture"]; - email?: parameters["rowFilter.user.email"]; - issuer?: parameters["rowFilter.user.issuer"]; - github?: parameters["rowFilter.user.github"]; - public_address?: parameters["rowFilter.user.public_address"]; - inserted_at?: parameters["rowFilter.user.inserted_at"]; - updated_at?: parameters["rowFilter.user.updated_at"]; + id?: parameters["rowFilter.user_customer.id"]; + user_id?: parameters["rowFilter.user_customer.user_id"]; + customer_id?: parameters["rowFilter.user_customer.customer_id"]; }; body: { - /** user */ - user?: definitions["user"]; + /** user_customer */ + user_customer?: definitions["user_customer"]; }; header: { /** Preference */ @@ -1604,6 +1592,36 @@ export interface paths { }; }; }; + "/rpc/upsert_user": { + post: { + parameters: { + body: { + args: { + /** Format: text */ + _github: string; + /** Format: text */ + _email: string; + /** Format: text */ + _name: string; + /** Format: text */ + _picture: string; + /** Format: text */ + _issuer: string; + /** Format: text */ + _public_address: string; + }; + }; + header: { + /** Preference */ + Prefer?: parameters["preferParams"]; + }; + }; + responses: { + /** OK */ + 200: unknown; + }; + }; + }; "/rpc/user_auth_keys_list": { post: { parameters: { @@ -2070,6 +2088,27 @@ export interface definitions { /** Format: public.auth_key_blocked_status_type */ status?: "Blocked" | "Unblocked"; }; + agreement: { + /** + * Format: bigint + * @description Note: + * This is a Primary Key. + */ + id: number; + /** + * Format: bigint + * @description Note: + * This is a Foreign Key to `user.id`. + */ + user_id: number; + /** Format: public.agreement_type */ + agreement: "web3.storage-tos-v1"; + /** + * Format: timestamp with time zone + * @default timezone('utc'::text, now()) + */ + inserted_at: string; + }; auth_key: { /** * Format: bigint @@ -2194,32 +2233,6 @@ export interface definitions { */ updated_at: string; }; - name: { - /** - * Format: text - * @description Note: - * This is a Primary Key. - */ - key: string; - /** Format: text */ - record: string; - /** Format: boolean */ - has_v2_sig: boolean; - /** Format: bigint */ - seqno: number; - /** Format: bigint */ - validity: number; - /** - * Format: timestamp with time zone - * @default timezone('utc'::text, now()) - */ - inserted_at: string; - /** - * Format: timestamp with time zone - * @default timezone('utc'::text, now()) - */ - updated_at: string; - }; pin: { /** * Format: bigint @@ -2340,27 +2353,6 @@ export interface definitions { */ updated_at: string; }; - terms_of_service: { - /** - * Format: bigint - * @description Note: - * This is a Primary Key. - */ - id: number; - /** - * Format: bigint - * @description Note: - * This is a Foreign Key to `user.id`. - */ - user_id: number; - /** Format: public.agreement_type */ - agreement: "web3.storage-tos-v1"; - /** - * Format: timestamp with time zone - * @default timezone('utc'::text, now()) - */ - inserted_at: string; - }; upload: { /** * Format: bigint @@ -2437,6 +2429,22 @@ export interface definitions { */ updated_at: string; }; + user_customer: { + /** + * Format: bigint + * @description Note: + * This is a Primary Key. + */ + id: number; + /** + * Format: bigint + * @description Note: + * This is a Foreign Key to `user.id`. + */ + user_id: number; + /** Format: text */ + customer_id: string; + }; user_tag: { /** * Format: bigint @@ -2549,6 +2557,16 @@ export interface parameters { "rowFilter.admin_search.reason": string; /** Format: public.auth_key_blocked_status_type */ "rowFilter.admin_search.status": string; + /** @description agreement */ + "body.agreement": definitions["agreement"]; + /** Format: bigint */ + "rowFilter.agreement.id": string; + /** Format: bigint */ + "rowFilter.agreement.user_id": string; + /** Format: public.agreement_type */ + "rowFilter.agreement.agreement": string; + /** Format: timestamp with time zone */ + "rowFilter.agreement.inserted_at": string; /** @description auth_key */ "body.auth_key": definitions["auth_key"]; /** Format: bigint */ @@ -2611,22 +2629,6 @@ export interface parameters { "rowFilter.metric.inserted_at": string; /** Format: timestamp with time zone */ "rowFilter.metric.updated_at": string; - /** @description name */ - "body.name": definitions["name"]; - /** Format: text */ - "rowFilter.name.key": string; - /** Format: text */ - "rowFilter.name.record": string; - /** Format: boolean */ - "rowFilter.name.has_v2_sig": string; - /** Format: bigint */ - "rowFilter.name.seqno": string; - /** Format: bigint */ - "rowFilter.name.validity": string; - /** Format: timestamp with time zone */ - "rowFilter.name.inserted_at": string; - /** Format: timestamp with time zone */ - "rowFilter.name.updated_at": string; /** @description pin */ "body.pin": definitions["pin"]; /** Format: bigint */ @@ -2683,16 +2685,6 @@ export interface parameters { "rowFilter.psa_pin_request.inserted_at": string; /** Format: timestamp with time zone */ "rowFilter.psa_pin_request.updated_at": string; - /** @description terms_of_service */ - "body.terms_of_service": definitions["terms_of_service"]; - /** Format: bigint */ - "rowFilter.terms_of_service.id": string; - /** Format: bigint */ - "rowFilter.terms_of_service.user_id": string; - /** Format: public.agreement_type */ - "rowFilter.terms_of_service.agreement": string; - /** Format: timestamp with time zone */ - "rowFilter.terms_of_service.inserted_at": string; /** @description upload */ "body.upload": definitions["upload"]; /** Format: bigint */ @@ -2737,6 +2729,14 @@ export interface parameters { "rowFilter.user.inserted_at": string; /** Format: timestamp with time zone */ "rowFilter.user.updated_at": string; + /** @description user_customer */ + "body.user_customer": definitions["user_customer"]; + /** Format: bigint */ + "rowFilter.user_customer.id": string; + /** Format: bigint */ + "rowFilter.user_customer.user_id": string; + /** Format: text */ + "rowFilter.user_customer.customer_id": string; /** @description user_tag */ "body.user_tag": definitions["user_tag"]; /** Format: bigint */ diff --git a/packages/db/postgres/tables.sql b/packages/db/postgres/tables.sql index 950255232d..9c9aeb75c8 100644 --- a/packages/db/postgres/tables.sql +++ b/packages/db/postgres/tables.sql @@ -326,15 +326,14 @@ CREATE TABLE IF NOT EXISTS psa_pin_request CREATE INDEX IF NOT EXISTS psa_pin_request_content_cid_idx ON psa_pin_request (content_cid); CREATE INDEX IF NOT EXISTS psa_pin_request_deleted_at_idx ON psa_pin_request (deleted_at) INCLUDE (content_cid, auth_key_id); -CREATE TABLE IF NOT EXISTS terms_of_service ( +CREATE TABLE IF NOT EXISTS agreement ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES public.user (id), - agreement agreement_type NOT NULL, - inserted_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL, - UNIQUE (user_id, agreement) + agreement agreement_type NOT NULL, + inserted_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL ); -CREATE INDEX IF NOT EXISTS terms_of_service_user_id_idx ON terms_of_service (user_id); +CREATE INDEX IF NOT EXISTS agreement_user_id_idx ON agreement (user_id); -- Metric contains the current values of collected metrics. CREATE TABLE IF NOT EXISTS metric diff --git a/packages/db/test/user.spec.js b/packages/db/test/user.spec.js index eb9dec6af7..c311fcb8d6 100644 --- a/packages/db/test/user.spec.js +++ b/packages/db/test/user.spec.js @@ -213,4 +213,11 @@ describe('user operations', () => { const thirdUsedStorage = await client.getStorageUsed(user._id) assert.strictEqual(thirdUsedStorage.uploaded, dagSize1, 'used storage with only first upload again') }) + + it('can createUserAgreement of web3.storage terms of service', async () => { + const agreement = /** @type {const} */ ('web3.storage-tos-v1') + await client.createUserAgreement(user._id, agreement) + // can create a second time. it will append another record of the second agreement with its own timestamp + await client.createUserAgreement(user._id, agreement) + }) }) diff --git a/packages/website/components/account/addPaymentMethodForm/addPaymentMethodForm.js b/packages/website/components/account/addPaymentMethodForm/addPaymentMethodForm.js index 7e9e31a025..54e3e7df94 100644 --- a/packages/website/components/account/addPaymentMethodForm/addPaymentMethodForm.js +++ b/packages/website/components/account/addPaymentMethodForm/addPaymentMethodForm.js @@ -2,9 +2,8 @@ import { useState } from 'react'; import { CardElement, useElements, useStripe } from '@stripe/react-stripe-js'; import Loading from '../../../components/loading/loading'; -import { APIError, defaultErrorMessageForEndUser, userBillingSettings } from '../../../lib/api'; +import { APIError, defaultErrorMessageForEndUser, saveDefaultPaymentMethod } from '../../../lib/api'; import Button from '../../../components/button/button'; -import { planIdToStorageSubscription } from '../../contexts/plansContext'; /** * @typedef {import('../../contexts/plansContext').Plan} Plan @@ -48,8 +47,7 @@ const AddPaymentMethodForm = ({ setHasPaymentMethods, setEditingPaymentMethod, c }); if (error) throw new Error(error.message); if (!paymentMethod?.id) return; - const currStorageSubscription = planIdToStorageSubscription(currentPlan); - await userBillingSettings(paymentMethod.id, currStorageSubscription); + await saveDefaultPaymentMethod(paymentMethod.id); setHasPaymentMethods?.(true); setEditingPaymentMethod?.(false); setPaymentMethodError(null); diff --git a/packages/website/components/accountPlansModal/accountPlansModal.js b/packages/website/components/accountPlansModal/accountPlansModal.js index a0d69b1903..4282907269 100644 --- a/packages/website/components/accountPlansModal/accountPlansModal.js +++ b/packages/website/components/accountPlansModal/accountPlansModal.js @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useCallback, useState } from 'react'; import { Elements } from '@stripe/react-stripe-js'; import Loading from '../../components/loading/loading.js'; @@ -7,28 +7,7 @@ import CurrentBillingPlanCard from '../../components/account/currentBillingPlanC import Modal from '../../modules/zero/components/modal/modal'; import CloseIcon from '../../assets/icons/close'; import AddPaymentMethodForm from '../../components/account/addPaymentMethodForm/addPaymentMethodForm.js'; -import { API, getToken } from '../../lib/api'; - -export async function putUserPayment(pm_id, plan_id) { - const storage = plan_id ? { price: plan_id } : null; - const paymentSettings = { - paymentMethod: { id: pm_id }, - subscription: { storage: storage }, - }; - const res = await fetch(API + '/user/payment', { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: 'Bearer ' + (await getToken()), - }, - body: JSON.stringify(paymentSettings), - }); - if (!res.ok) { - throw new Error(`failed to get storage info: ${await res.text()}`); - } - - return res.json(); -} +import { isW3STermsOfServiceAgreement, tosAgreementVersions, userBillingSettings } from '../../lib/api'; /** * @@ -39,8 +18,8 @@ export async function putUserPayment(pm_id, plan_id) { * @param {object} obj * @param {any} obj.isOpen * @param {any} obj.onClose - * @param {undefined|Plan} obj.planSelection - * @param {any} obj.planList + * @param {import('components/contexts/plansContext.js').Plan} obj.planSelection + * @param {import('components/contexts/plansContext.js').Plan[]} obj.planList * @param {any} obj.stripePromise * @param {any} obj.setCurrentPlan * @param {any} obj.savedPaymentMethod @@ -63,7 +42,32 @@ const AccountPlansModal = ({ stripePromise, }) => { const [isCreatingSub, setIsCreatingSub] = useState(false); - const currentPlan = planList.find(p => p.id === planSelection?.id); + const currentPlan = planList.find(p => p.id === planSelection.id); + const [consentedTosAgreement, setConsentedTosAgreement] = useState( + /** @type {import('../../lib/api').W3STermsOfServiceAgreement|null} */ (null) + ); + // onChange handler for checkbox for terms of service agreement + const onTosAgreementCheckboxChange = useCallback( + /** + * When user clicks the tos agreement checkbox, update state indicating which agreement they consented to. + * Checkbox specifies agreement in data-agreement attribute; + * @param {import('react').ChangeEvent} event + */ + event => { + setHasAcceptedTerms(!hasAcceptedTerms); + const probablyTosAgreement = event.target.dataset.agreement; + if (event.target.checked) { + if (isW3STermsOfServiceAgreement(probablyTosAgreement)) { + setConsentedTosAgreement(probablyTosAgreement); + } else { + throw new Error(`unexpected agreement value for terms checkbox: ${probablyTosAgreement}`); + } + } else { + setConsentedTosAgreement(null); + } + }, + [hasAcceptedTerms, setHasAcceptedTerms, setConsentedTosAgreement] + ); return (
setHasAcceptedTerms(!hasAcceptedTerms)} + // agreement that this checkbox indicates consenting to + data-agreement={tosAgreementVersions[1]} + onChange={onTosAgreementCheckboxChange} />
- { - setIsPaymentPlanModalOpen(false); - setHasAcceptedTerms(false); - if (planQueryParam) { - removePlanQueryParam(); - } - }} - planList={planList} - planSelection={planSelection} - setCurrentPlan={setOptimisticCurrentPlan} - savedPaymentMethod={savedPaymentMethod} - stripePromise={stripePromise} - setHasPaymentMethods={() => setNeedsFetchPaymentSettings(true)} - setEditingPaymentMethod={setEditingPaymentMethod} - setHasAcceptedTerms={setHasAcceptedTerms} - hasAcceptedTerms={hasAcceptedTerms} - /> + {planSelection && ( + <> + { + setIsPaymentPlanModalOpen(false); + setHasAcceptedTerms(false); + if (planQueryParam) { + removePlanQueryParam(); + } + }} + planList={planList} + planSelection={planSelection} + setCurrentPlan={setOptimisticCurrentPlan} + savedPaymentMethod={savedPaymentMethod} + stripePromise={stripePromise} + setHasPaymentMethods={() => setNeedsFetchPaymentSettings(true)} + setEditingPaymentMethod={setEditingPaymentMethod} + setHasAcceptedTerms={setHasAcceptedTerms} + hasAcceptedTerms={hasAcceptedTerms} + /> + + )} );