Repository: adamgibbons/ics
Pull request #244 (2023-06-13 20:53:57)

CUTYPE and X-NUM-GUESTS support
this is a tweak of PR #237 with the following updates:

* change the schema so that xNumGuests is a number
* restrict SEQUENCE to the max for an integer, per [RFC 5545](https://datatracker.ietf.org/doc/html/rfc5545#section-3.8.7.4) (I have found that if it is larger than the signed int max, gmail will ignore it)
* RSVP should be optional (only use it if it is specified to be true or false)
* add some tests for the new fields


Patch:
diff --git a/index.d.ts b/index.d.ts
index 809a995..572ab67 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -34,6 +34,13 @@ export type ParticipationRole =
   | 'OPT-PARTICIPANT'
   | 'NON-PARTICIPANT';
 
+export type ParticipationType =
+  | 'INDIVIDUAL'
+  | 'GROUP'
+  | 'RESOURCE'
+  | 'ROOM'
+  | 'UNKNOWN';
+
 export type Person = {
   name?: string;
   email?: string;
@@ -44,6 +51,8 @@ export type Attendee = Person & {
   rsvp?: boolean;
   partstat?: ParticipationStatus;
   role?: ParticipationRole;
+  cutype?: ParticipationType;
+  xNumGuests?: number;
 };
 
 export type ActionType = 'audio' | 'display' | 'email' | 'procedure';
@@ -81,15 +90,15 @@ export type EventAttributes = {
   url?: string;
   status?: EventStatus;
   busyStatus?: 'FREE' | 'BUSY' | 'TENTATIVE' | 'OOF';
-  
+
   organizer?: Person & {
     sentBy?: string;
   };
   attendees?: Attendee[];
-  
+
   categories?: string[];
   alarms?: Alarm[];
-  
+
   productId?: string;
   uid?: string;
   method?: string;
diff --git a/src/schema/index.js b/src/schema/index.js
index eb35227..52f26a1 100644
--- a/src/schema/index.js
+++ b/src/schema/index.js
@@ -34,7 +34,9 @@ const contactSchema = yup.object().shape({
   rsvp: yup.boolean(),
   dir: yup.string().matches(urlRegex),
   partstat: yup.string(),
-  role: yup.string()
+  role: yup.string(),
+  cutype: yup.string(),
+  xNumGuests: yup.number()
 }).noUnknown()
 
 const organizerSchema = yup.object().shape({
@@ -65,7 +67,7 @@ const schema = yup.object().shape({
   productId: yup.string(),
   method: yup.string(),
   uid: yup.string().required(),
-  sequence: yup.number(),
+  sequence: yup.number().integer().max(2_147_483_647),
   start: dateTimeSchema.required(),
   duration: durationSchema,
   startType: yup.string().matches(/utc|local/),
diff --git a/src/utils/set-contact.js b/src/utils/set-contact.js
index 07d8073..cee2e09 100644
--- a/src/utils/set-contact.js
+++ b/src/utils/set-contact.js
@@ -1,12 +1,27 @@
-export default function setContact({ name, email, rsvp, dir, partstat, role }) {
-  let formattedAttendee = ''
-  formattedAttendee += rsvp ? 'RSVP=TRUE;' : 'RSVP=FALSE;'
-  formattedAttendee += role ? `ROLE=${role};` : ''
-  formattedAttendee += partstat ? `PARTSTAT=${partstat};` : ''
-  formattedAttendee += dir ? `DIR=${dir};` : ''
-  formattedAttendee += 'CN='
-  formattedAttendee += name || 'Unnamed attendee'
-  formattedAttendee += email ? `:mailto:${email}` : ''
+export default function setContact({ name, email, rsvp, dir, partstat, role, cutype, xNumGuests }) {
+  let formattedParts = [];
+
+  if(rsvp !== undefined){
+    formattedParts.push(rsvp ? 'RSVP=TRUE' : 'RSVP=FALSE');
+  }
+  if(cutype){
+    formattedParts.push("CUTYPE=".concat(cutype));
+  }
+  if(xNumGuests !== undefined){
+    formattedParts.push(`X-NUM-GUESTS=${xNumGuests}`);
+  }
+  if(role){
+    formattedParts.push("ROLE=".concat(role));
+  }
+  if(partstat){
+    formattedParts.push("PARTSTAT=".concat(partstat));
+  }
+  if(dir){
+    formattedParts.push("DIR=".concat(dir));
+  }
+  formattedParts.push('CN='.concat((name || 'Unnamed attendee')));
+
+  var formattedAttendee = formattedParts.join(';').concat(email ? ":mailto:".concat(email) : '');
 
   return formattedAttendee
 }


Test patch:
diff --git a/test/pipeline/format.spec.js b/test/pipeline/format.spec.js
index 7aebc41..d8cc7ae 100644
--- a/test/pipeline/format.spec.js
+++ b/test/pipeline/format.spec.js
@@ -175,7 +175,7 @@ describe('pipeline.formatEvent', () => {
       {name: 'Brittany Seaton', email: 'brittany@example.com', rsvp: true }
     ]})
     const formattedEvent = formatEvent(event)
-    expect(formattedEvent).to.contain('ATTENDEE;RSVP=FALSE;CN=Adam Gibbons:mailto:adam@example.com')
+    expect(formattedEvent).to.contain('ATTENDEE;CN=Adam Gibbons:mailto:adam@example.com')
     expect(formattedEvent).to.contain('ATTENDEE;RSVP=TRUE;CN=Brittany Seaton:mailto:brittany@example.com')
   })
   it('writes a busystatus', () => {
diff --git a/test/pipeline/validate.spec.js b/test/pipeline/validate.spec.js
index 9085687..6820428 100644
--- a/test/pipeline/validate.spec.js
+++ b/test/pipeline/validate.spec.js
@@ -11,6 +11,15 @@ describe('pipeline.validate', () => {
     expect(error).not.to.exist
     expect(value.uid).to.equal('1')
   })
+  it('returns an error if the sequence number is too long', () => {
+    const { error, value } = validateEvent({
+      uid: '1',
+      start: [1997, 10, 1, 22, 30],
+      duration: { hours: 1 },
+      sequence: 3_456_789_123, // bigger than 2,147,483,647
+    })
+    expect(error).to.exist
+  })
   it('returns undefined when passed no event', () => {
     const { error, value } = validateEvent()
     expect(value).to.be.undefined
diff --git a/test/utils/set-contact.spec.js b/test/utils/set-contact.spec.js
index ca99a9a..3e9fb8d 100644
--- a/test/utils/set-contact.spec.js
+++ b/test/utils/set-contact.spec.js
@@ -4,20 +4,24 @@ import { setContact } from '../../src/utils'
 describe('utils.setContact', () => {
   it('set a contact with role', () => {
     const contact = { name: 'm-vinc', email: 'vinc@example.com' }
+    expect(setContact(contact))
+    .to.equal(`CN=m-vinc:mailto:vinc@example.com`)
+
     const contactChair = Object.assign({role: 'CHAIR'}, contact)
-    const contactRequired = Object.assign({role: 'REQ-PARTICIPANT' }, contact)
-    const contactOptional = Object.assign({role: 'OPT-PARTICIPANT' }, contact)
-    const contactNon = Object.assign({role: 'NON-PARTICIPANT' }, contact)
     expect(setContact(contactChair))
-    .to.equal(`RSVP=FALSE;ROLE=CHAIR;CN=m-vinc:mailto:vinc@example.com`)
+    .to.equal(`ROLE=CHAIR;CN=m-vinc:mailto:vinc@example.com`)
+
+    const contactRequired = Object.assign({role: 'REQ-PARTICIPANT', rsvp: true }, contact)
     expect(setContact(contactRequired))
-    .to.equal(`RSVP=FALSE;ROLE=REQ-PARTICIPANT;CN=m-vinc:mailto:vinc@example.com`)
+    .to.equal(`RSVP=TRUE;ROLE=REQ-PARTICIPANT;CN=m-vinc:mailto:vinc@example.com`)
+
+    const contactOptional = Object.assign({role: 'OPT-PARTICIPANT', rsvp: false }, contact)
     expect(setContact(contactOptional))
     .to.equal(`RSVP=FALSE;ROLE=OPT-PARTICIPANT;CN=m-vinc:mailto:vinc@example.com`)
+
+    const contactNon = Object.assign({role: 'NON-PARTICIPANT' }, contact)
     expect(setContact(contactNon))
-    .to.equal(`RSVP=FALSE;ROLE=NON-PARTICIPANT;CN=m-vinc:mailto:vinc@example.com`)
-    expect(setContact(contact))
-    .to.equal(`RSVP=FALSE;CN=m-vinc:mailto:vinc@example.com`)
+    .to.equal(`ROLE=NON-PARTICIPANT;CN=m-vinc:mailto:vinc@example.com`)
   })
   it('set a contact with partstat', () => {
     const contact = { name: 'm-vinc', email: 'vinc@example.com' }
@@ -28,21 +32,21 @@ describe('utils.setContact', () => {
     const contactTentative = Object.assign({contact, partstat: 'TENTATIVE'}, contact)
 
     expect(setContact(contactUndefined))
-    .to.equal('RSVP=FALSE;CN=m-vinc:mailto:vinc@example.com')
+    .to.equal('CN=m-vinc:mailto:vinc@example.com')
 
     expect(setContact(contactNeedsAction))
-    .to.equal('RSVP=FALSE;PARTSTAT=NEEDS-ACTION;CN=m-vinc:mailto:vinc@example.com')
+    .to.equal('PARTSTAT=NEEDS-ACTION;CN=m-vinc:mailto:vinc@example.com')
 
     expect(setContact(contactDeclined))
-    .to.equal('RSVP=FALSE;PARTSTAT=DECLINED;CN=m-vinc:mailto:vinc@example.com')
+    .to.equal('PARTSTAT=DECLINED;CN=m-vinc:mailto:vinc@example.com')
 
     expect(setContact(contactTentative))
-    .to.equal('RSVP=FALSE;PARTSTAT=TENTATIVE;CN=m-vinc:mailto:vinc@example.com')
-    
+    .to.equal('PARTSTAT=TENTATIVE;CN=m-vinc:mailto:vinc@example.com')
+
     expect(setContact(contactAccepted))
-    .to.equal('RSVP=FALSE;PARTSTAT=ACCEPTED;CN=m-vinc:mailto:vinc@example.com')
+    .to.equal('PARTSTAT=ACCEPTED;CN=m-vinc:mailto:vinc@example.com')
   })
-  it('sets a contact and defaults RSVP to false', () => {
+  it('sets a contact and only sets RSVP if specified', () => {
     const contact1 = {
       name: 'Adam Gibbons',
       email: 'adam@example.com'
@@ -56,9 +60,17 @@ describe('utils.setContact', () => {
     }
 
     expect(setContact(contact1))
-    .to.equal('RSVP=FALSE;CN=Adam Gibbons:mailto:adam@example.com')
+    .to.equal('CN=Adam Gibbons:mailto:adam@example.com')
 
     expect(setContact(contact2))
     .to.equal('RSVP=TRUE;DIR=https://example.com/contacts/adam;CN=Adam Gibbons:mailto:adam@example.com')
   })
+  it('set a contact with cutype and guests', () => {
+    const contact = { name: 'm-vinc', email: 'vinc@example.com' }
+    const contactCuGuests = Object.assign({ cutype: 'INDIVIDUAL', xNumGuests: 0 }, contact)
+    const contactString = setContact(contactCuGuests)
+
+    expect(contactString).to.contain('CUTYPE=INDIVIDUAL')
+    expect(contactString).to.contain('X-NUM-GUESTS=0')
+  })
 })