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" ); + } ); + } ); } );