From 49499569f750c3cc7369b1caa25d7031d8235531 Mon Sep 17 00:00:00 2001 From: Patrick Leary Date: Thu, 19 Oct 2023 09:53:22 -0400 Subject: [PATCH 1/2] add platform parameter to announcements endpoint --- .../v1/announcements_controller.js | 11 ++++++++- .../schema/request/announcements_search.js | 6 +++++ openapi/schema/response/announcement.js | 1 + schema/database.sql | 3 ++- schema/fixtures.js | 24 +++++++++++++++---- test/integration/v2/announcements.js | 22 +++++++++++++++++ 6 files changed, 61 insertions(+), 6 deletions(-) diff --git a/lib/controllers/v1/announcements_controller.js b/lib/controllers/v1/announcements_controller.js index 8cff39bc..3b229e14 100644 --- a/lib/controllers/v1/announcements_controller.js +++ b/lib/controllers/v1/announcements_controller.js @@ -8,7 +8,7 @@ const Site = require( "../../models/site" ); const AnnouncementsController = class AnnouncementsController { static async search( req ) { let query = squel.select( ) - .field( "announcements.id, body, placement, dismissible, locales, start, \"end\"" ) + .field( "announcements.id, body, placement, dismissible, locales, platforms, start, \"end\"" ) .from( "announcements" ) .where( "NOW() at time zone 'utc' between start and \"end\"" ) .order( "announcements.id" ); @@ -29,6 +29,15 @@ const AnnouncementsController = class AnnouncementsController { query = query.where( placementClause ); } + if ( req.query.platform ) { + // given a platform parameter, return only announcements that include that platform, + // or announcements with no platform specified + query = query.where( "? = ANY( platforms ) OR platforms IS NULL OR platforms = '{}'", req.query.platform ); + } else { + // if there is no platform parameter, return only announcements with no platform specified + query = query.where( "platforms IS NULL OR platforms = '{}'" ); + } + // site_id filter if ( req.userSession ) { // authenticated requests include announcements targeted at the users site, diff --git a/openapi/schema/request/announcements_search.js b/openapi/schema/request/announcements_search.js index c3de620f..b8ce8935 100644 --- a/openapi/schema/request/announcements_search.js +++ b/openapi/schema/request/announcements_search.js @@ -8,6 +8,12 @@ module.exports = Joi.object( ).keys( { "mobile/home", "mobile" ), + platform: Joi.string( ).valid( + "inat-ios", + "inat-android", + "seek", + "inatrn" + ), locale: Joi.string( ), fields: Joi.any( ) } ).unknown( false ); diff --git a/openapi/schema/response/announcement.js b/openapi/schema/response/announcement.js index 8dde9cd1..f9e8f8e4 100644 --- a/openapi/schema/response/announcement.js +++ b/openapi/schema/response/announcement.js @@ -4,6 +4,7 @@ module.exports = Joi.object( ).keys( { id: Joi.number( ).integer( ).required( ), body: Joi.string( ), placement: Joi.string( ), + platforms: Joi.array( ).items( Joi.string( ) ), dismissible: Joi.boolean( ), locales: Joi.array( ).items( Joi.string( ) ), start: Joi.date( ), diff --git a/schema/database.sql b/schema/database.sql index bd2d6bc8..0cd3df9e 100644 --- a/schema/database.sql +++ b/schema/database.sql @@ -322,7 +322,8 @@ CREATE TABLE public.announcements ( updated_at timestamp without time zone, locales text[] DEFAULT '{}'::text[], dismiss_user_ids integer[] DEFAULT '{}'::integer[], - dismissible boolean DEFAULT false + dismissible boolean DEFAULT false, + platforms text[] DEFAULT '{}'::text[] ); diff --git a/schema/fixtures.js b/schema/fixtures.js index f2ef08ec..5a278458 100644 --- a/schema/fixtures.js +++ b/schema/fixtures.js @@ -1851,7 +1851,8 @@ "updated_at": "2023-01-01 00:00:00", "placement": "mobile/home", "locales": "{}", - "dismissible": true + "dismissible": true, + "platforms": "{}" }, { "id": 2, @@ -1862,7 +1863,8 @@ "updated_at": "2023-01-01 00:00:00", "placement": "mobile/home", "locales": "{}", - "dismissible": true + "dismissible": true, + "platforms": "{}" }, { "id": 3, @@ -1873,7 +1875,8 @@ "updated_at": "2023-01-01 00:00:00", "placement": "mobile/home", "locales": "{en-US,fr}", - "dismissible": true + "dismissible": true, + "platforms": "{}" }, { "id": 4, @@ -1884,7 +1887,20 @@ "updated_at": "2023-01-01 00:00:00", "placement": "mobile/home", "locales": "{en}", - "dismissible": true + "dismissible": true, + "platforms": "{}" + }, + { + "id": 5, + "body": "Active announcement for inat-ios and inat-android", + "start": "2023-01-01 00:00:00", + "end": "2100-01-01 00:00:00", + "created_at": "2023-01-01 00:00:00", + "updated_at": "2023-01-01 00:00:00", + "placement": "mobile/home", + "locales": "{}", + "dismissible": true, + "platforms": "{inat-ios,inat-android}" } ], "comments": [ diff --git a/test/integration/v2/announcements.js b/test/integration/v2/announcements.js index 2a23dcd6..2ad4d50d 100644 --- a/test/integration/v2/announcements.js +++ b/test/integration/v2/announcements.js @@ -69,6 +69,28 @@ describe( "Announcements", ( ) => { } ).expect( "Content-Type", /json/ ) .expect( 200, done ); } ); + + it( "returns announcements based on platform", function ( done ) { + const inatiOSAnnouncement = _.find( + fixtures.postgresql.announcements, a => a.platforms.match( /inat-ios/ ) + ); + request( this.app ).get( "/v2/announcements?fields=all&platform=inat-ios" ).expect( res => { + expect( res.body.results ).to.not.be.empty; + expect( _.every( res.body.results, r => ( + r.platforms.includes( "inat-ios" ) || _.isEmpty( r.platforms ) + ) ) ).to.be.true; + expect( _.map( res.body.results, "id" ) ).to.include( inatiOSAnnouncement.id ); + } ).expect( "Content-Type", /json/ ) + .expect( 200, done ); + } ); + + it( "does not return announcements with a platform not matching parameter", function ( done ) { + request( this.app ).get( "/v2/announcements?fields=all&platform=seek" ).expect( res => { + expect( res.body.results ).to.not.be.empty; + expect( _.every( res.body.results, r => _.isEmpty( r.platforms ) ) ).to.be.true; + } ).expect( "Content-Type", /json/ ) + .expect( 200, done ); + } ); } ); describe( "dismiss", ( ) => { From aeb0e7f1cccd280d7a34f989cac4d973e865f055 Mon Sep 17 00:00:00 2001 From: Patrick Leary Date: Fri, 20 Oct 2023 13:21:43 -0400 Subject: [PATCH 2/2] rename announcement platforms to clients; add auto-detection of announcement client using user agent --- .../v1/announcements_controller.js | 17 +++--- lib/util.js | 21 ++++++++ .../schema/request/announcements_search.js | 2 +- openapi/schema/response/announcement.js | 2 +- schema/database.sql | 2 +- schema/fixtures.js | 10 ++-- test/integration/v2/announcements.js | 44 ++++++++++++--- test/util.js | 54 +++++++++++++++++++ 8 files changed, 130 insertions(+), 22 deletions(-) diff --git a/lib/controllers/v1/announcements_controller.js b/lib/controllers/v1/announcements_controller.js index 3b229e14..d5ff5536 100644 --- a/lib/controllers/v1/announcements_controller.js +++ b/lib/controllers/v1/announcements_controller.js @@ -4,11 +4,12 @@ const { announcements } = require( "inaturalistjs" ); const InaturalistAPI = require( "../../inaturalist_api" ); const pgClient = require( "../../pg_client" ); const Site = require( "../../models/site" ); +const util = require( "../../util" ); const AnnouncementsController = class AnnouncementsController { static async search( req ) { let query = squel.select( ) - .field( "announcements.id, body, placement, dismissible, locales, platforms, start, \"end\"" ) + .field( "announcements.id, body, placement, dismissible, locales, clients, start, \"end\"" ) .from( "announcements" ) .where( "NOW() at time zone 'utc' between start and \"end\"" ) .order( "announcements.id" ); @@ -29,13 +30,15 @@ const AnnouncementsController = class AnnouncementsController { query = query.where( placementClause ); } - if ( req.query.platform ) { - // given a platform parameter, return only announcements that include that platform, - // or announcements with no platform specified - query = query.where( "? = ANY( platforms ) OR platforms IS NULL OR platforms = '{}'", req.query.platform ); + const userAgentClient = util.userAgentClient( req ); + if ( req.query.client || userAgentClient ) { + // given a client parameter, return only announcements that include that client, + // or announcements with no client specified + query = query.where( "? = ANY( clients ) OR clients IS NULL OR clients = '{}'", + req.query.client || userAgentClient ); } else { - // if there is no platform parameter, return only announcements with no platform specified - query = query.where( "platforms IS NULL OR platforms = '{}'" ); + // if there is no client parameter, return only announcements with no client specified + query = query.where( "clients IS NULL OR clients = '{}'" ); } // site_id filter diff --git a/lib/util.js b/lib/util.js index c86ba302..87d133c8 100644 --- a/lib/util.js +++ b/lib/util.js @@ -610,6 +610,27 @@ const util = class util { } return arr[Math.floor( length / 2 )]; } + + static userAgentClient( req ) { + const userAgent = req.headers["user-agent"]; + if ( _.isEmpty( userAgent ) ) { + return null; + } + if ( userAgent.match( /iNaturalistReactNative\// ) + || userAgent.match( /iNaturalistRN\// ) ) { + return "inatrn"; + } + if ( userAgent.match( /Seek\// ) ) { + return "seek"; + } + if ( userAgent.match( /iNaturalist\/.*Darwin/ ) ) { + return "inat-ios"; + } + if ( userAgent.match( /iNaturalist\/.*Android/ ) ) { + return "inat-android"; + } + return null; + } }; util.iucnValues = { diff --git a/openapi/schema/request/announcements_search.js b/openapi/schema/request/announcements_search.js index b8ce8935..2838c4a1 100644 --- a/openapi/schema/request/announcements_search.js +++ b/openapi/schema/request/announcements_search.js @@ -8,7 +8,7 @@ module.exports = Joi.object( ).keys( { "mobile/home", "mobile" ), - platform: Joi.string( ).valid( + client: Joi.string( ).valid( "inat-ios", "inat-android", "seek", diff --git a/openapi/schema/response/announcement.js b/openapi/schema/response/announcement.js index f9e8f8e4..3946501e 100644 --- a/openapi/schema/response/announcement.js +++ b/openapi/schema/response/announcement.js @@ -4,7 +4,7 @@ module.exports = Joi.object( ).keys( { id: Joi.number( ).integer( ).required( ), body: Joi.string( ), placement: Joi.string( ), - platforms: Joi.array( ).items( Joi.string( ) ), + clients: Joi.array( ).items( Joi.string( ) ), dismissible: Joi.boolean( ), locales: Joi.array( ).items( Joi.string( ) ), start: Joi.date( ), diff --git a/schema/database.sql b/schema/database.sql index 0cd3df9e..ad6d3233 100644 --- a/schema/database.sql +++ b/schema/database.sql @@ -323,7 +323,7 @@ CREATE TABLE public.announcements ( locales text[] DEFAULT '{}'::text[], dismiss_user_ids integer[] DEFAULT '{}'::integer[], dismissible boolean DEFAULT false, - platforms text[] DEFAULT '{}'::text[] + clients text[] DEFAULT '{}'::text[] ); diff --git a/schema/fixtures.js b/schema/fixtures.js index 5a278458..528b50e0 100644 --- a/schema/fixtures.js +++ b/schema/fixtures.js @@ -1852,7 +1852,7 @@ "placement": "mobile/home", "locales": "{}", "dismissible": true, - "platforms": "{}" + "clients": "{}" }, { "id": 2, @@ -1864,7 +1864,7 @@ "placement": "mobile/home", "locales": "{}", "dismissible": true, - "platforms": "{}" + "clients": "{}" }, { "id": 3, @@ -1876,7 +1876,7 @@ "placement": "mobile/home", "locales": "{en-US,fr}", "dismissible": true, - "platforms": "{}" + "clients": "{}" }, { "id": 4, @@ -1888,7 +1888,7 @@ "placement": "mobile/home", "locales": "{en}", "dismissible": true, - "platforms": "{}" + "clients": "{}" }, { "id": 5, @@ -1900,7 +1900,7 @@ "placement": "mobile/home", "locales": "{}", "dismissible": true, - "platforms": "{inat-ios,inat-android}" + "clients": "{inat-ios,inat-android}" } ], "comments": [ diff --git a/test/integration/v2/announcements.js b/test/integration/v2/announcements.js index 2ad4d50d..2a63342f 100644 --- a/test/integration/v2/announcements.js +++ b/test/integration/v2/announcements.js @@ -70,27 +70,57 @@ describe( "Announcements", ( ) => { .expect( 200, done ); } ); - it( "returns announcements based on platform", function ( done ) { + it( "returns announcements based on client", function ( done ) { const inatiOSAnnouncement = _.find( - fixtures.postgresql.announcements, a => a.platforms.match( /inat-ios/ ) + fixtures.postgresql.announcements, a => a.clients.match( /inat-ios/ ) ); - request( this.app ).get( "/v2/announcements?fields=all&platform=inat-ios" ).expect( res => { + request( this.app ).get( "/v2/announcements?fields=all&client=inat-ios" ).expect( res => { expect( res.body.results ).to.not.be.empty; expect( _.every( res.body.results, r => ( - r.platforms.includes( "inat-ios" ) || _.isEmpty( r.platforms ) + r.clients.includes( "inat-ios" ) || _.isEmpty( r.clients ) ) ) ).to.be.true; expect( _.map( res.body.results, "id" ) ).to.include( inatiOSAnnouncement.id ); } ).expect( "Content-Type", /json/ ) .expect( 200, done ); } ); - it( "does not return announcements with a platform not matching parameter", function ( done ) { - request( this.app ).get( "/v2/announcements?fields=all&platform=seek" ).expect( res => { + it( "returns announcements based on user agent", function ( done ) { + const inatiOSAnnouncement = _.find( + fixtures.postgresql.announcements, a => a.clients.match( /inat-ios/ ) + ); + request( this.app ) + .get( "/v2/announcements?fields=all" ) + .set( "User-Agent", "iNaturalist/708 CFNetwork/1410.0.3 Darwin/22.6.0" ) + .expect( res => { + expect( res.body.results ).to.not.be.empty; + expect( _.every( res.body.results, r => ( + r.clients.includes( "inat-ios" ) || _.isEmpty( r.clients ) + ) ) ).to.be.true; + expect( _.map( res.body.results, "id" ) ).to.include( inatiOSAnnouncement.id ); + } ) + .expect( "Content-Type", /json/ ) + .expect( 200, done ); + } ); + + it( "does not return announcements with a client not matching parameter", function ( done ) { + request( this.app ).get( "/v2/announcements?fields=all&client=seek" ).expect( res => { expect( res.body.results ).to.not.be.empty; - expect( _.every( res.body.results, r => _.isEmpty( r.platforms ) ) ).to.be.true; + expect( _.every( res.body.results, r => _.isEmpty( r.clients ) ) ).to.be.true; } ).expect( "Content-Type", /json/ ) .expect( 200, done ); } ); + + it( "does not return announcements with a client not matching parameter", function ( done ) { + request( this.app ) + .get( "/v2/announcements?fields=all" ) + .set( "User-Agent", "Seek/2.15.3 Handset (Build 316) Android/13" ) + .expect( res => { + expect( res.body.results ).to.not.be.empty; + expect( _.every( res.body.results, r => _.isEmpty( r.clients ) ) ).to.be.true; + } ) + .expect( "Content-Type", /json/ ) + .expect( 200, done ); + } ); } ); describe( "dismiss", ( ) => { diff --git a/test/util.js b/test/util.js index c3345c4a..a5b9bca1 100644 --- a/test/util.js +++ b/test/util.js @@ -101,4 +101,58 @@ describe( "util", ( ) => { expect( opts.locale ).to.eq( "he-il" ); // not sure why it's lowercase... } ); } ); + + describe( "userAgentClient", ( ) => { + it( "returns nil when request user agent is empty", ( ) => { + expect( util.userAgentClient( { headers: { } } ) ).to.be.null; + expect( util.userAgentClient( { headers: { "user-agent": null } } ) ).to.be.null; + expect( util.userAgentClient( { headers: { "user-agent": "" } } ) ).to.be.null; + } ); + + it( "returns nil when request user agent is unrecognized", ( ) => { + expect( util.userAgentClient( { headers: { "user-agent": "nonsense" } } ) ).to.be.null; + } ); + + it( "recognizes inatrn client user agents", ( ) => { + expect( util.userAgentClient( { + headers: { + "user-agent": "iNaturalistReactNative/60 CFNetwork/1474 Darwin/23.0.0" + } + } ) ).to.eq( "inatrn" ); + expect( util.userAgentClient( { + headers: { + "user-agent": "iNaturalistRN/0.14.0 Handset (Build 60) iOS/17.0.3" + } + } ) ).to.eq( "inatrn" ); + } ); + + it( "recognizes seek client user agents", ( ) => { + expect( util.userAgentClient( { + headers: { + "user-agent": "Seek/2.15.3 Handset (Build 316) iOS/17.0.3" + } + } ) ).to.eq( "seek" ); + expect( util.userAgentClient( { + headers: { + "user-agent": "Seek/2.15.3 Handset (Build 316) Android/13" + } + } ) ).to.eq( "seek" ); + } ); + + it( "recognizes inat-ios client user agents", ( ) => { + expect( util.userAgentClient( { + headers: { + "user-agent": "iNaturalist/708 CFNetwork/1410.0.3 Darwin/22.6.0" + } + } ) ).to.eq( "inat-ios" ); + } ); + + it( "recognizes inat-android client user agents", ( ) => { + expect( util.userAgentClient( { + headers: { + "user-agent": "iNaturalist/1.29.18 (Build 592; Android 5.10.157-android13-4-00001-g5c7ff5dc7aac-ab10381520 10754064; SDK 34; bluejay Pixel 6a bluejay; OS Version 14)" + } + } ) ).to.eq( "inat-android" ); + } ); + } ); } );