diff --git a/common/models/subscription.js b/common/models/subscription.js index 84ec8c9..7594c23 100644 --- a/common/models/subscription.js +++ b/common/models/subscription.js @@ -537,7 +537,11 @@ module.exports = function(Subscription) { } } - Subscription.prototype.verify = async function(options, confirmationCode) { + Subscription.prototype.verify = async function( + options, + confirmationCode, + replace + ) { let mergedSubscriptionConfig = await Subscription.getMergedConfig( 'subscription', this.serviceName @@ -581,8 +585,31 @@ module.exports = function(Subscription) { error.status = 403 return await handleConfirmationAcknowledgement(error) } - this.state = 'confirmed' try { + if (replace && this.userChannelId) { + let whereClause = { + serviceName: this.serviceName, + state: 'confirmed', + channel: this.channel + } + // email address check should be case insensitive + let escapedUserChannelId = this.userChannelId.replace( + /[-[\]{}()*+?.,\\^$|#\s]/g, + '\\$&' + ) + let escapedUserChannelIdRegExp = new RegExp(escapedUserChannelId, 'i') + whereClause.userChannelId = { + regexp: escapedUserChannelIdRegExp + } + await Subscription.updateAll( + whereClause, + { + state: 'deleted' + }, + options + ) + } + this.state = 'confirmed' await Subscription.replaceById(this.id, this, options) } catch (err) { return await handleConfirmationAcknowledgement(err) diff --git a/common/models/subscription.json b/common/models/subscription.json index 1f21978..93e0c10 100644 --- a/common/models/subscription.json +++ b/common/models/subscription.json @@ -173,6 +173,15 @@ "http": { "source": "query" } + }, + { + "arg": "replace", + "type": "boolean", + "required": false, + "description": "whether or not replacing existing subscriptions", + "http": { + "source": "query" + } } ], "returns": [ @@ -202,9 +211,7 @@ "returns": [ { "arg": "result", - "type": [ - "string" - ], + "type": ["string"], "root": true, "description": "operation outcome" } @@ -218,4 +225,4 @@ ] } } -} \ No newline at end of file +} diff --git a/docs/_docs/api-subscription.md b/docs/_docs/api-subscription.md index 9076405..8b114c0 100644 --- a/docs/_docs/api-subscription.md +++ b/docs/_docs/api-subscription.md @@ -390,6 +390,11 @@ GET /subscriptions/{id}/verify * required: true * parameter type: query * data type: string + * whether or not replacing existing subscriptions + * parameter name: replace + * required: false + * parameter type: query + * data type: boolean * outcome *NotifyBC* performs following actions in sequence @@ -397,9 +402,10 @@ GET /subscriptions/{id}/verify 1. the subscription identified by *id* is retrieved 2. for user request, the *userId* of the subscription is checked against current request user, if not match, error is returned; otherwise 3. input parameter *confirmationCode* is checked against *confirmationRequest.confirmationCode*. If not match, error is returned; otherwise - 4. *state* is set to *confirmed* - 5. the subscription is saved back to database - 6. displays acknowledgement message according to [configuration](../config-subscription#confirmation-verification-acknowledgement-messages) + 4. if input parameter *replace* is supplied and set to *true*, then existing confirmed subscriptions from the same *serviceName*, *channel* and *userChannelId* are deleted. No unsubscription acknowledgement notification is sent + 5. *state* is set to *confirmed* + 6. the subscription is saved back to database + 7. displays acknowledgement message according to [configuration](../config-subscription#confirmation-verification-acknowledgement-messages) ## Update a Subscription ``` diff --git a/docs/_docs/config-subscription.md b/docs/_docs/config-subscription.md index e5ebb8e..d8d708e 100644 --- a/docs/_docs/config-subscription.md +++ b/docs/_docs/config-subscription.md @@ -96,7 +96,7 @@ If error happened during subscription confirmation, query string *?err=\ ## Duplicated Subscription *NotifyBC* by default allows a user subscribe to a service through same channel multiple times. If this is undesirable, you can set config *subscription.detectDuplicatedSubscription* to true. In such case instead of sending user a confirmation request, *NotifyBC* sends user a duplicated subscription notification message. Unlike a confirmation request, duplicated subscription -notification message doesn't and shouldn't contain any information to allow user confirm the subscription. You can customize duplicated subscription notification message by setting config *subscription.duplicatedSubscriptionNotification* in either *config.local.js* or using configuration api for service-specific dynamic config. Following is the default settings defined in +notification message either shouldn't contain any information to allow user confirm the subscription, or it should contain a link that allows user to replace existing confirmed subscription with this one. You can customize duplicated subscription notification message by setting config *subscription.duplicatedSubscriptionNotification* in either *config.local.js* or using configuration api for service-specific dynamic config. Following is the default settings defined in *config.json* ```json @@ -119,6 +119,24 @@ notification message doesn't and shouldn't contain any information to allow user } ``` +To allow user to replace existing confirmed subscription, set the message to something like + +```json +{ + ... + "subscription": { + ... + "detectDuplicatedSubscription": false, + "duplicatedSubscriptionNotification": { + "email": { + "textBody": "A duplicated subscription was submitted. If the request is not submitted by you, please ignore this message. Otherwise if you want to replace existing subscription with this one, click {subscription_confirmation_url}&replace=true." + } + } + } +} +``` +The query parameter *&replace=true* following the token *{subscription_confirmation_url}* will cause existing subscription be replaced. + ## Anonymous Unsubscription For anonymous subscription, *NotifyBC* supports one-click opt-out by allowing unsubscription URL provided in notifications. To thwart unauthorized unsubscription attempts, *NotifyBC* implemented and enabled by default two security measurements diff --git a/spec/app/subscription.spec.js b/spec/app/subscription.spec.js index f2f4130..10419ac 100644 --- a/spec/app/subscription.spec.js +++ b/spec/app/subscription.spec.js @@ -476,11 +476,6 @@ describe('GET /subscriptions/{id}/verify', function() { userChannelId: 'bar@foo.com', state: 'unconfirmed', confirmationRequest: { - confirmationCodeRegex: '\\d{5}', - sendRequest: true, - from: 'no_reply@invlid.local', - subject: 'Subscription confirmation', - textBody: 'enter {confirmation_code} in this email', confirmationCode: '37688' } }, @@ -497,14 +492,21 @@ describe('GET /subscriptions/{id}/verify', function() { userChannelId: 'bar@foo.com', state: 'unconfirmed', confirmationRequest: { - confirmationCodeRegex: '\\d{5}', - sendRequest: true, - from: 'no_reply@invlid.local', - subject: 'Subscription confirmation', - textBody: 'enter {confirmation_code} in this email', confirmationCode: '37689' - }, - unsubscriptionCode: '50032' + } + }, + function(err, res) { + cb(err, res) + } + ) + }, + function(cb) { + app.models.Subscription.create( + { + serviceName: 'myService', + channel: 'email', + userChannelId: 'bar@foo.com', + state: 'confirmed' }, function(err, res) { cb(err, res) @@ -527,24 +529,33 @@ describe('GET /subscriptions/{id}/verify', function() { }) it('should verify confirmation code sent by anonymous user', async function() { - let res = await request(app) - .get( - '/api/subscriptions/' + data[1].id + '/verify?confirmationCode=37689' - ) - .set('Accept', 'application/json') + let res = await request(app).get( + '/api/subscriptions/' + data[1].id + '/verify?confirmationCode=37689' + ) expect(res.statusCode).toBe(200) res = await app.models.Subscription.findById(data[1].id) expect(res.state).toBe('confirmed') }) it('should deny incorrect confirmation code', async function() { - let res = await request(app) - .get('/api/subscriptions/' + data[1].id + '/verify?confirmationCode=0000') - .set('Accept', 'application/json') + let res = await request(app).get( + '/api/subscriptions/' + data[1].id + '/verify?confirmationCode=0000' + ) expect(res.statusCode).toBe(403) res = await app.models.Subscription.findById(data[1].id) expect(res.state).toBe('unconfirmed') }) + + it('should unsubscribe existing subscriptions when replace paramter is supplied', async function() { + let res = await request(app).get( + '/api/subscriptions/' + + data[1].id + + '/verify?confirmationCode=37689&replace=true' + ) + expect(res.statusCode).toBe(200) + res = await app.models.Subscription.findById(data[2].id) + expect(res.state).toBe('deleted') + }) }) describe('DELETE /subscriptions/{id}', function() {